Skip to content

Commit 4f762f8

Browse files
committed
feat: Permanent API key auth for n8n — wos- prefix keys never expire
1 parent b965930 commit 4f762f8

7 files changed

Lines changed: 456 additions & 4 deletions

File tree

backend/app/core/email.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,53 @@ async def send_morning_briefing_email(to: str, name: str, briefing_text: str) ->
7272
</div>
7373
"""
7474
return await send_email(to, subject, body_html, briefing_text)
75+
76+
77+
async def send_medicine_reminder(to: str, name: str, medicine_name: str, dose: str, time: str) -> bool:
78+
subject = f"💊 Medicine Reminder — {medicine_name}"
79+
body_html = f"""
80+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #0f172a; color: #e2e8f0; padding: 40px; border-radius: 12px;">
81+
<h1 style="color: #3b82f6;">WILLIAM OS</h1>
82+
<h2>💊 Medicine Reminder</h2>
83+
<p>Hey {name}, time to take your medicine.</p>
84+
<div style="background: #1e293b; padding: 20px; border-radius: 8px; margin: 20px 0;">
85+
<p><strong>Medicine:</strong> {medicine_name}</p>
86+
<p><strong>Dose:</strong> {dose}</p>
87+
<p><strong>Time:</strong> {time}</p>
88+
</div>
89+
<a href="https://williamos.duckdns.org/medicine" style="display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none;">
90+
Log Dose ✅
91+
</a>
92+
</div>
93+
"""
94+
return await send_email(to, subject, body_html)
95+
96+
97+
async def send_daily_calendar(to: str, name: str, events: list, habits: list) -> bool:
98+
subject = f"📅 Today's Schedule — William OS"
99+
100+
events_html = "".join([
101+
f'<li style="padding: 8px 0; border-bottom: 1px solid #334155;">'
102+
f'<strong>{e.get("title","")}</strong> at {e.get("start","")}</li>'
103+
for e in events
104+
]) or "<li>No events today</li>"
105+
106+
habits_html = "".join([
107+
f'<li style="padding: 8px 0; border-bottom: 1px solid #334155;">☐ {h}</li>'
108+
for h in habits
109+
]) or "<li>No habits due</li>"
110+
111+
body_html = f"""
112+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #0f172a; color: #e2e8f0; padding: 40px; border-radius: 12px;">
113+
<h1 style="color: #3b82f6;">WILLIAM OS</h1>
114+
<h2>📅 Good morning {name}</h2>
115+
<h3>Today's Calendar</h3>
116+
<ul style="list-style: none; padding: 0;">{events_html}</ul>
117+
<h3>Habits Due Today</h3>
118+
<ul style="list-style: none; padding: 0;">{habits_html}</ul>
119+
<a href="https://williamos.duckdns.org/dashboard" style="display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; margin-top: 20px;">
120+
Open Dashboard
121+
</a>
122+
</div>
123+
"""
124+
return await send_email(to, subject, body_html)

backend/app/modules/auth/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class User(Base):
6363
permission_scopes: Mapped[list[str]] = mapped_column(JSONB, default=list)
6464

6565
# Journal vault passphrase hash (separate from login password)
66+
notification_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
6667
journal_passphrase_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
6768

6869
# Relationships

backend/app/modules/auth/routes.py

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,32 @@ def _clear_refresh_cookie(response: Response) -> None:
5757

5858
async def get_current_user_id(
5959
authorization: str = Header(..., description="Bearer <token>"),
60+
db: AsyncSession = Depends(get_db),
6061
) -> uuid.UUID:
61-
"""Extract and validate user ID from JWT."""
62+
"""Extract and validate user ID from JWT or permanent API key."""
6263
if not authorization.startswith("Bearer "):
6364
from app.shared.types import AuthenticationError
64-
6565
raise AuthenticationError("Invalid authorization header")
66-
token = authorization[7:]
66+
token = authorization[7:].strip()
67+
if token.startswith("wos-"):
68+
from sqlalchemy import text
69+
result = await db.execute(
70+
text("SELECT user_id FROM auth.api_keys WHERE key_hash=:key AND is_active=true"),
71+
{"key": token}
72+
)
73+
row = result.fetchone()
74+
if not row:
75+
from app.shared.types import AuthenticationError
76+
raise AuthenticationError("Invalid API key")
77+
await db.execute(
78+
text("UPDATE auth.api_keys SET last_used=NOW() WHERE key_hash=:key"),
79+
{"key": token}
80+
)
81+
await db.commit()
82+
return uuid.UUID(str(row[0]))
6783
payload = decode_token(token)
6884
if payload.get("type") != "access":
6985
from app.shared.types import AuthenticationError
70-
7186
raise AuthenticationError("Invalid token type")
7287
return uuid.UUID(payload["sub"])
7388

@@ -270,3 +285,35 @@ async def login_history(
270285
rows = await service.list_login_history(user_id=user_id, limit=limit)
271286
payload = [LoginHistoryResponse.model_validate(row).model_dump(mode="json") for row in rows]
272287
return success(payload)
288+
289+
290+
@router.post("/api-keys")
291+
async def create_api_key(
292+
payload: dict,
293+
user_id: uuid.UUID = Depends(get_current_user_id),
294+
db: AsyncSession = Depends(get_db),
295+
) -> dict:
296+
import secrets
297+
from sqlalchemy import text
298+
key = f"wos-{secrets.token_hex(24)}"
299+
name = payload.get("name", "API Key")
300+
await db.execute(
301+
text("INSERT INTO auth.api_keys (user_id, key_hash, name) VALUES (:uid, :key, :name)"),
302+
{"uid": str(user_id), "key": key, "name": name}
303+
)
304+
await db.commit()
305+
return success({"key": key, "name": name, "note": "Save this key — it won't be shown again"})
306+
307+
308+
@router.get("/api-keys")
309+
async def list_api_keys(
310+
user_id: uuid.UUID = Depends(get_current_user_id),
311+
db: AsyncSession = Depends(get_db),
312+
) -> dict:
313+
from sqlalchemy import text
314+
result = await db.execute(
315+
text("SELECT id, name, LEFT(key_hash,12) || '...' as key_hint, created_at FROM auth.api_keys WHERE user_id=:uid"),
316+
{"uid": str(user_id)}
317+
)
318+
keys = [dict(r._mapping) for r in result.fetchall()]
319+
return success(keys)

0 commit comments

Comments
 (0)