feat: persona onboarding — invite tokens, self-service setup, persona creation, switcher

New user flow:
  1. Admin: python manage_passwords.py invite <username>  → generates URL
  2. User visits /setup/<token> → sets own password → logged in
  3. User redirected to /setup/persona → fills name/emoji/description
  4. persona_template.py generates all starter files → lands at /{user}/{persona}

Multiple personas:
  - Header persona name is now a clickable dropdown listing all personas
  - "New persona" link at bottom → /setup/persona (available to logged-in users)
  - /api/personas endpoint returns persona list for current session user

New files:
  - persona_template.py: generates IDENTITY/SOUL/PROTOCOLS/USER/HELP.md + data files
  - routers/onboarding.py: /setup/{token}, /setup/persona GET+POST
  - static/setup.html: two-step form (password → persona), emoji picker, mobile-friendly

Updated:
  - auth_utils.py: create_invite(), validate_invite(), consume_invite()
  - manage_passwords.py: invite command with URL output
  - auth_middleware.py: /setup/* prefix is public (invite tokens need no auth)
  - routers/ui.py: /api/personas endpoint; post-login redirect if no personas
  - static/app.js: persona switcher dropdown with navigation + Add persona link
  - static/style.css: .persona-switcher, .persona-dropdown, mobile adjustments

Mobile: login/setup pages are card-centered with responsive padding;
dropdown avoids edge-clipping on narrow screens; logout button stays visible.

All 80 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-20 23:10:32 -04:00
parent a9bbb668b5
commit 46b65d087c
11 changed files with 851 additions and 13 deletions

View File

@@ -13,6 +13,7 @@ Usage:
import json
import logging
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -70,3 +71,68 @@ def decode_token(token: str) -> str:
"""Decode a JWT and return the username. Raises jwt.InvalidTokenError on failure."""
payload = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
return payload["sub"]
# ---------------------------------------------------------------------------
# Invite tokens — one-time setup links for new users
# ---------------------------------------------------------------------------
def _invite_path(username: str) -> Path:
return settings.home_root() / username / "invite.json"
def create_invite(username: str, expire_hours: int = 72) -> str:
"""
Generate a one-time invite token for a user and save it to invite.json.
Returns the raw token string (embed in a URL).
"""
token = secrets.token_urlsafe(32)
expires = (datetime.now(timezone.utc) + timedelta(hours=expire_hours)).isoformat()
user_dir = settings.home_root() / username
user_dir.mkdir(parents=True, exist_ok=True)
_invite_path(username).write_text(
json.dumps({"token": token, "expires_at": expires, "used": False}) + "\n"
)
logger.info("invite created for user: %s (expires %s)", username, expires[:10])
return token
def validate_invite(token: str) -> str | None:
"""
Check an invite token across all users.
Returns the username if valid and unused, None otherwise.
"""
root = settings.home_root()
if not root.exists():
return None
for user_dir in root.iterdir():
if not user_dir.is_dir():
continue
inv_path = user_dir / "invite.json"
if not inv_path.exists():
continue
try:
data = json.loads(inv_path.read_text())
except Exception:
continue
if data.get("used"):
continue
if data.get("token") != token:
continue
expires = datetime.fromisoformat(data["expires_at"])
if datetime.now(timezone.utc) > expires:
continue
return user_dir.name
return None
def consume_invite(username: str) -> None:
"""Mark the invite token for a user as used."""
path = _invite_path(username)
if path.exists():
try:
data = json.loads(path.read_text())
data["used"] = True
path.write_text(json.dumps(data) + "\n")
except Exception:
pass