diff --git a/src/porchlight/admin/routes.py b/src/porchlight/admin/routes.py
index 634cd17..44283d6 100644
--- a/src/porchlight/admin/routes.py
+++ b/src/porchlight/admin/routes.py
@@ -1,3 +1,6 @@
+from base64 import urlsafe_b64decode
+from urllib.parse import urlparse
+
from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
@@ -117,3 +120,229 @@ async def create_invite(
return HTMLResponse(
f'
Invite created for {username}:
{url}
'
)
+
+
+# --- Profile update ---
+@router.post("/users/{userid}/profile", response_class=HTMLResponse)
+async def update_user_profile(
+ request: Request,
+ userid: str,
+ given_name: str = Form(""),
+ family_name: str = Form(""),
+ preferred_username: str = Form(""),
+ email: str = Form(""),
+ phone_number: str = Form(""),
+ picture: str = Form(""),
+ locale: str = Form(""),
+) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ # Validate
+ if email and "@" not in email:
+ return HTMLResponse('Invalid email address
')
+ if picture:
+ parsed = urlparse(picture)
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
+ return HTMLResponse('Picture URL must be a valid HTTP or HTTPS URL
')
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ updated = user.model_copy(
+ update={
+ "given_name": given_name or None,
+ "family_name": family_name or None,
+ "preferred_username": preferred_username or None,
+ "email": email or None,
+ "phone_number": phone_number or None,
+ "picture": picture or None,
+ "locale": locale or None,
+ }
+ )
+ await user_repo.update(updated)
+ return HTMLResponse('Profile updated
')
+
+
+# --- Groups update ---
+@router.post("/users/{userid}/groups", response_class=HTMLResponse)
+async def update_user_groups(
+ request: Request,
+ userid: str,
+ groups: str = Form(""),
+) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ group_list = [g.strip() for g in groups.split(",") if g.strip()]
+ updated = user.model_copy(update={"groups": group_list})
+ await user_repo.update(updated)
+ return HTMLResponse('Groups updated
')
+
+
+# --- Activate / deactivate ---
+@router.post("/users/{userid}/activate", response_class=HTMLResponse)
+async def activate_user(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ updated = user.model_copy(update={"active": True})
+ await user_repo.update(updated)
+ return HTMLResponse(
+ 'User activated
'
+ f''
+ )
+
+
+@router.post("/users/{userid}/deactivate", response_class=HTMLResponse)
+async def deactivate_user(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ updated = user.model_copy(update={"active": False})
+ await user_repo.update(updated)
+ return HTMLResponse(
+ 'User deactivated
'
+ f''
+ )
+
+
+# --- Delete credentials ---
+@router.delete("/users/{userid}/credentials/password", response_class=HTMLResponse)
+async def delete_user_password(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ cred_repo = request.app.state.credential_repo
+ await cred_repo.delete_password(userid)
+
+ # Re-render credentials section
+ webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
+ templates = request.app.state.templates
+ return templates.TemplateResponse(
+ request,
+ "admin/_credentials_section.html",
+ {
+ "target_user": await request.app.state.user_repo.get_by_userid(userid),
+ "webauthn_credentials": webauthn_credentials,
+ "has_password": False,
+ },
+ )
+
+
+@router.delete("/users/{userid}/credentials/webauthn/{credential_id_b64}", response_class=HTMLResponse)
+async def delete_user_webauthn(request: Request, userid: str, credential_id_b64: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ padded = credential_id_b64 + "=" * (-len(credential_id_b64) % 4)
+ credential_id = urlsafe_b64decode(padded)
+ cred_repo = request.app.state.credential_repo
+ await cred_repo.delete_webauthn(userid, credential_id)
+
+ # Re-render credentials section
+ webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
+ password = await cred_repo.get_password_by_user(userid)
+ templates = request.app.state.templates
+ return templates.TemplateResponse(
+ request,
+ "admin/_credentials_section.html",
+ {
+ "target_user": await request.app.state.user_repo.get_by_userid(userid),
+ "webauthn_credentials": webauthn_credentials,
+ "has_password": password is not None,
+ },
+ )
+
+
+# --- Re-invite ---
+@router.post("/users/{userid}/invite", response_class=HTMLResponse)
+async def reinvite_user(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ user_repo = request.app.state.user_repo
+ user = await user_repo.get_by_userid(userid)
+ if user is None:
+ return HTMLResponse("User not found", status_code=404)
+
+ magic_link_service = request.app.state.magic_link_service
+ settings = request.app.state.settings
+ link = await magic_link_service.create(username=user.username, created_by=admin.username, note="admin re-invite")
+ url = f"{settings.issuer}/register/{link.token}"
+
+ return HTMLResponse(f'Invite link generated:
{url}
')
+
+
+# --- Delete user ---
+@router.delete("/users/{userid}", response_class=HTMLResponse)
+async def delete_user(request: Request, userid: str) -> Response:
+ session_user = get_session_user(request)
+ if session_user is None:
+ return RedirectResponse("/login", status_code=303)
+ admin = await _get_admin_user(request)
+ if admin is None:
+ return HTMLResponse("Forbidden", status_code=403)
+
+ # Prevent self-deletion
+ admin_userid, _ = get_session_user(request)
+ if userid == admin_userid:
+ return HTMLResponse('Cannot delete your own account
')
+
+ user_repo = request.app.state.user_repo
+ deleted = await user_repo.delete(userid)
+ if not deleted:
+ return HTMLResponse("User not found", status_code=404)
+
+ return HTMLResponse(
+ status_code=200,
+ content='User deleted
',
+ headers={"HX-Redirect": "/admin/users"},
+ )
diff --git a/src/porchlight/templates/admin/_credentials_section.html b/src/porchlight/templates/admin/_credentials_section.html
new file mode 100644
index 0000000..b2351eb
--- /dev/null
+++ b/src/porchlight/templates/admin/_credentials_section.html
@@ -0,0 +1,27 @@
+Password
+{% if has_password %}
+Password is set.
+
+
+{% else %}
+No password set.
+{% endif %}
+
+Security keys
+{% if webauthn_credentials %}
+
+ {% for cred in webauthn_credentials %}
+ -
+ {{ cred.device_name or "Security key" }}
+ (added {{ cred.created_at.strftime('%Y-%m-%d') }})
+
+
+ {% endfor %}
+
+{% else %}
+No security keys registered.
+{% endif %}