Merge branch 'feature/admin-pages'
# Conflicts: # src/porchlight/app.py
This commit is contained in:
commit
33a61ecc2a
20 changed files with 1542 additions and 0 deletions
0
src/porchlight/admin/__init__.py
Normal file
0
src/porchlight/admin/__init__.py
Normal file
348
src/porchlight/admin/routes.py
Normal file
348
src/porchlight/admin/routes.py
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
from base64 import urlsafe_b64decode
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form, Request, Response
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from porchlight.dependencies import get_session_user
|
||||||
|
from porchlight.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_admin_user(request: Request) -> User | None:
|
||||||
|
"""Return the current user if they are an admin, else None."""
|
||||||
|
session_user = get_session_user(request)
|
||||||
|
if session_user is None:
|
||||||
|
return None
|
||||||
|
userid, _username = session_user
|
||||||
|
user_repo = request.app.state.user_repo
|
||||||
|
user = await user_repo.get_by_userid(userid)
|
||||||
|
if user is None or "admin" not in user.groups:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_class=HTMLResponse)
|
||||||
|
async def users_list(request: Request) -> 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)
|
||||||
|
|
||||||
|
per_page = 20
|
||||||
|
q = request.query_params.get("q", "")
|
||||||
|
offset = int(request.query_params.get("offset", "0"))
|
||||||
|
|
||||||
|
user_repo = request.app.state.user_repo
|
||||||
|
if q:
|
||||||
|
users = await user_repo.search_users(q, offset, per_page)
|
||||||
|
total = await user_repo.count_users(query=q)
|
||||||
|
else:
|
||||||
|
users = await user_repo.list_users(offset, per_page)
|
||||||
|
total = await user_repo.count_users()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"users": users,
|
||||||
|
"query": q,
|
||||||
|
"offset": offset,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total": total,
|
||||||
|
"active_page": "users",
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTMX search requests get just the table rows partial
|
||||||
|
if request.headers.get("HX-Request") and request.headers.get("HX-Trigger-Name") == "q":
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse(request, "admin/_user_rows.html", context)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse(request, "admin/users.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{userid}", response_class=HTMLResponse)
|
||||||
|
async def user_detail(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
|
||||||
|
cred_repo = request.app.state.credential_repo
|
||||||
|
target_user = await user_repo.get_by_userid(userid)
|
||||||
|
if target_user is None:
|
||||||
|
return HTMLResponse("User not found", status_code=404)
|
||||||
|
|
||||||
|
webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
|
||||||
|
password_credential = await cred_repo.get_password_by_user(userid)
|
||||||
|
|
||||||
|
templates = request.app.state.templates
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"admin/user_detail.html",
|
||||||
|
{
|
||||||
|
"target_user": target_user,
|
||||||
|
"webauthn_credentials": webauthn_credentials,
|
||||||
|
"has_password": password_credential is not None,
|
||||||
|
"active_page": "users",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invite", response_class=HTMLResponse)
|
||||||
|
async def create_invite(
|
||||||
|
request: Request,
|
||||||
|
username: 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)
|
||||||
|
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
return HTMLResponse('<div role="alert">Username is required</div>')
|
||||||
|
|
||||||
|
magic_link_service = request.app.state.magic_link_service
|
||||||
|
settings = request.app.state.settings
|
||||||
|
link = await magic_link_service.create(username=username, created_by=admin.username, note="admin invite")
|
||||||
|
url = f"{settings.issuer}/register/{link.token}"
|
||||||
|
|
||||||
|
return HTMLResponse(
|
||||||
|
f'<div role="status">Invite created for <strong>{username}</strong>:</div><div class="invite-url">{url}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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('<div role="alert">Invalid email address</div>')
|
||||||
|
if picture:
|
||||||
|
parsed = urlparse(picture)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
return HTMLResponse('<div role="alert">Picture URL must be a valid HTTP or HTTPS URL</div>')
|
||||||
|
|
||||||
|
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('<div role="status">Profile updated</div>')
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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('<div role="status">Groups updated</div>')
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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(
|
||||||
|
'<div role="status">User activated</div>'
|
||||||
|
f'<button class="btn-secondary" hx-post="/admin/users/{userid}/deactivate"'
|
||||||
|
' hx-target="#actions-section" hx-swap="innerHTML">Deactivate user</button>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
||||||
|
'<div role="status">User deactivated</div>'
|
||||||
|
f'<button class="btn-secondary" hx-post="/admin/users/{userid}/activate"'
|
||||||
|
' hx-target="#actions-section" hx-swap="innerHTML">Activate user</button>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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'<div role="status">Invite link generated:</div><div class="invite-url">{url}</div>')
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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('<div role="alert">Cannot delete your own account</div>')
|
||||||
|
|
||||||
|
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='<div role="status">User deleted</div>',
|
||||||
|
headers={"HX-Redirect": "/admin/users"},
|
||||||
|
)
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import secrets
|
import secrets
|
||||||
|
from base64 import urlsafe_b64encode
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -10,6 +11,7 @@ from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from porchlight.admin.routes import router as admin_router
|
||||||
from porchlight.authn.password import PasswordService
|
from porchlight.authn.password import PasswordService
|
||||||
from porchlight.authn.routes import router as authn_router
|
from porchlight.authn.routes import router as authn_router
|
||||||
from porchlight.authn.webauthn import WebAuthnService
|
from porchlight.authn.webauthn import WebAuthnService
|
||||||
|
|
@ -128,12 +130,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
return generate_csrf_token(request)
|
return generate_csrf_token(request)
|
||||||
|
|
||||||
templates.env.globals["csrf_token_processor"] = csrf_token_processor
|
templates.env.globals["csrf_token_processor"] = csrf_token_processor
|
||||||
|
templates.env.filters["b64encode"] = lambda v: urlsafe_b64encode(v).decode().rstrip("=")
|
||||||
app.state.templates = templates
|
app.state.templates = templates
|
||||||
|
|
||||||
# Static files
|
# Static files
|
||||||
app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")
|
app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")
|
||||||
|
|
||||||
# Routers
|
# Routers
|
||||||
|
app.include_router(admin_router)
|
||||||
app.include_router(authn_router)
|
app.include_router(authn_router)
|
||||||
app.include_router(manage_router)
|
app.include_router(manage_router)
|
||||||
app.include_router(oidc_router)
|
app.include_router(oidc_router)
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,13 @@ async def credentials_page(request: Request) -> Response:
|
||||||
|
|
||||||
userid, username = session_user
|
userid, username = session_user
|
||||||
cred_repo = request.app.state.credential_repo
|
cred_repo = request.app.state.credential_repo
|
||||||
|
user_repo = request.app.state.user_repo
|
||||||
|
|
||||||
webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
|
webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
|
||||||
password_credential = await cred_repo.get_password_by_user(userid)
|
password_credential = await cred_repo.get_password_by_user(userid)
|
||||||
setup = request.query_params.get("setup")
|
setup = request.query_params.get("setup")
|
||||||
|
user = await user_repo.get_by_userid(userid)
|
||||||
|
is_admin = user is not None and "admin" in user.groups
|
||||||
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
|
@ -41,6 +44,7 @@ async def credentials_page(request: Request) -> Response:
|
||||||
"has_password": password_credential is not None,
|
"has_password": password_credential is not None,
|
||||||
"setup": setup,
|
"setup": setup,
|
||||||
"active_page": "credentials",
|
"active_page": "credentials",
|
||||||
|
"is_admin": is_admin,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -177,6 +181,7 @@ async def profile_page(request: Request) -> Response:
|
||||||
userid, username = session_user
|
userid, username = session_user
|
||||||
user_repo = request.app.state.user_repo
|
user_repo = request.app.state.user_repo
|
||||||
user = await user_repo.get_by_userid(userid)
|
user = await user_repo.get_by_userid(userid)
|
||||||
|
is_admin = user is not None and "admin" in user.groups
|
||||||
|
|
||||||
templates = request.app.state.templates
|
templates = request.app.state.templates
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
|
@ -186,6 +191,7 @@ async def profile_page(request: Request) -> Response:
|
||||||
"username": username,
|
"username": username,
|
||||||
"user": user,
|
"user": user,
|
||||||
"active_page": "profile",
|
"active_page": "profile",
|
||||||
|
"is_admin": is_admin,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,169 @@ main {
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Admin ---------- */
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav a {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding-bottom: var(--sp-3);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav a:hover {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav a[aria-current="page"] {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: var(--accent);
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tag button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-tag button:hover {
|
||||||
|
color: var(--error-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-search {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-search input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--sp-4);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-detail section {
|
||||||
|
margin-bottom: var(--sp-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-url {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-danger {
|
||||||
|
background: var(--error-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--error-fg) 20%, transparent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.status-active {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error-fg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Typography ---------- */
|
/* ---------- Typography ---------- */
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ class UserRepository(Protocol):
|
||||||
|
|
||||||
async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]: ...
|
async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]: ...
|
||||||
|
|
||||||
|
async def search_users(self, query: str, offset: int = 0, limit: int = 100) -> list[User]: ...
|
||||||
|
|
||||||
|
async def count_users(self, query: str | None = None) -> int: ...
|
||||||
|
|
||||||
async def delete(self, userid: str) -> bool: ...
|
async def delete(self, userid: str) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,32 @@ class SQLiteUserRepository:
|
||||||
users.append(self._row_to_user(row, groups))
|
users.append(self._row_to_user(row, groups))
|
||||||
return users
|
return users
|
||||||
|
|
||||||
|
async def search_users(self, query: str, offset: int = 0, limit: int = 100) -> list[User]:
|
||||||
|
pattern = f"%{query}%"
|
||||||
|
async with self._db.execute(
|
||||||
|
"SELECT * FROM users WHERE username LIKE ? OR email LIKE ? ORDER BY username LIMIT ? OFFSET ?",
|
||||||
|
(pattern, pattern, limit, offset),
|
||||||
|
) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
users = []
|
||||||
|
for row in rows:
|
||||||
|
groups = await self._get_groups(row["userid"])
|
||||||
|
users.append(self._row_to_user(row, groups))
|
||||||
|
return users
|
||||||
|
|
||||||
|
async def count_users(self, query: str | None = None) -> int:
|
||||||
|
if query:
|
||||||
|
pattern = f"%{query}%"
|
||||||
|
async with self._db.execute(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE username LIKE ? OR email LIKE ?",
|
||||||
|
(pattern, pattern),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
else:
|
||||||
|
async with self._db.execute("SELECT COUNT(*) FROM users") as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
async def delete(self, userid: str) -> bool:
|
async def delete(self, userid: str) -> bool:
|
||||||
cursor = await self._db.execute("DELETE FROM users WHERE userid = ?", (userid,))
|
cursor = await self._db.execute("DELETE FROM users WHERE userid = ?", (userid,))
|
||||||
await self._db.commit()
|
await self._db.commit()
|
||||||
|
|
|
||||||
27
src/porchlight/templates/admin/_credentials_section.html
Normal file
27
src/porchlight/templates/admin/_credentials_section.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<h3>Password</h3>
|
||||||
|
{% if has_password %}
|
||||||
|
<p>Password is set.
|
||||||
|
<button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}/credentials/password"
|
||||||
|
hx-target="#credentials-section" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Remove this user's password?">Remove password</button>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p>No password set.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>Security keys</h3>
|
||||||
|
{% if webauthn_credentials %}
|
||||||
|
<ul>
|
||||||
|
{% for cred in webauthn_credentials %}
|
||||||
|
<li>
|
||||||
|
{{ cred.device_name or "Security key" }}
|
||||||
|
<small>(added {{ cred.created_at.strftime('%Y-%m-%d') }})</small>
|
||||||
|
<button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}/credentials/webauthn/{{ cred.credential_id|b64encode }}"
|
||||||
|
hx-target="#credentials-section" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Remove this security key?">Remove</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No security keys registered.</p>
|
||||||
|
{% endif %}
|
||||||
13
src/porchlight/templates/admin/_pagination.html
Normal file
13
src/porchlight/templates/admin/_pagination.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% if total > 0 %}
|
||||||
|
<span>
|
||||||
|
Showing {{ offset + 1 }}–{{ offset + users|length }} of {{ total }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>
|
||||||
|
{% if offset > 0 %}
|
||||||
|
<a href="/admin/users?offset={{ offset - per_page }}&q={{ query or '' }}">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if offset + per_page < total %}
|
||||||
|
<a href="/admin/users?offset={{ offset + per_page }}&q={{ query or '' }}">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
21
src/porchlight/templates/admin/_user_rows.html
Normal file
21
src/porchlight/templates/admin/_user_rows.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/admin/users/{{ user.userid }}">{{ user.username }}</a></td>
|
||||||
|
<td>{{ [user.given_name, user.family_name]|select|join(' ') }}</td>
|
||||||
|
<td>{{ user.email or '' }}</td>
|
||||||
|
<td>{% for g in user.groups %}<span class="group-tag">{{ g }}</span> {% endfor %}</td>
|
||||||
|
<td>
|
||||||
|
<span id="status-{{ user.userid }}">
|
||||||
|
{% if user.active %}
|
||||||
|
<span class="status-badge status-active">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge status-inactive">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not users %}
|
||||||
|
<tr><td colspan="6">No users found.</td></tr>
|
||||||
|
{% endif %}
|
||||||
9
src/porchlight/templates/admin/base.html
Normal file
9
src/porchlight/templates/admin/base.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav class="admin-nav" aria-label="Administration">
|
||||||
|
<span class="admin-badge">Admin</span>
|
||||||
|
<a href="/admin/users" {% if active_page == "users" %}aria-current="page"{% endif %}>Users</a>
|
||||||
|
</nav>
|
||||||
|
{% block admin_content %}{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
124
src/porchlight/templates/admin/user_detail.html
Normal file
124
src/porchlight/templates/admin/user_detail.html
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ target_user.username }} — Admin — Porchlight{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<h1>{{ target_user.username }}</h1>
|
||||||
|
<p>ID: <code>{{ target_user.userid }}</code> · Created {{ target_user.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||||
|
|
||||||
|
<div class="admin-detail">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Profile</h2>
|
||||||
|
<form hx-post="/admin/users/{{ target_user.userid }}/profile" hx-target="#profile-status" hx-swap="innerHTML">
|
||||||
|
<div>
|
||||||
|
<label for="given_name">Given name</label>
|
||||||
|
<input type="text" id="given_name" name="given_name" value="{{ target_user.given_name or '' }}" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="family_name">Family name</label>
|
||||||
|
<input type="text" id="family_name" name="family_name" value="{{ target_user.family_name or '' }}" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="preferred_username">Display name</label>
|
||||||
|
<input type="text" id="preferred_username" name="preferred_username" value="{{ target_user.preferred_username or '' }}" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" value="{{ target_user.email or '' }}" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="phone_number">Phone number</label>
|
||||||
|
<input type="tel" id="phone_number" name="phone_number" value="{{ target_user.phone_number or '' }}" maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="picture">Picture URL</label>
|
||||||
|
<input type="url" id="picture" name="picture" value="{{ target_user.picture or '' }}" maxlength="2048">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="locale">Locale</label>
|
||||||
|
<input type="text" id="locale" name="locale" value="{{ target_user.locale or '' }}" maxlength="20" placeholder="e.g. en, sv-SE">
|
||||||
|
</div>
|
||||||
|
<div id="profile-status"></div>
|
||||||
|
<button type="submit">Save profile</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Groups</h2>
|
||||||
|
<div id="groups-section">
|
||||||
|
<form hx-post="/admin/users/{{ target_user.userid }}/groups" hx-target="#groups-status" hx-swap="innerHTML">
|
||||||
|
<div id="group-list">
|
||||||
|
{% for group in target_user.groups %}
|
||||||
|
<span class="group-tag">{{ group }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="groups">Groups (comma-separated)</label>
|
||||||
|
<input type="text" id="groups" name="groups" value="{{ target_user.groups|join(', ') }}">
|
||||||
|
</div>
|
||||||
|
<div id="groups-status"></div>
|
||||||
|
<button type="submit">Update groups</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Credentials</h2>
|
||||||
|
<div id="credentials-section">
|
||||||
|
<h3>Password</h3>
|
||||||
|
{% if has_password %}
|
||||||
|
<p>Password is set.
|
||||||
|
<button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}/credentials/password"
|
||||||
|
hx-target="#credentials-section" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Remove this user's password?">Remove password</button>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p>No password set.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h3>Security keys</h3>
|
||||||
|
{% if webauthn_credentials %}
|
||||||
|
<ul>
|
||||||
|
{% for cred in webauthn_credentials %}
|
||||||
|
<li>
|
||||||
|
{{ cred.device_name or "Security key" }}
|
||||||
|
<small>(added {{ cred.created_at.strftime('%Y-%m-%d') }})</small>
|
||||||
|
<button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}/credentials/webauthn/{{ cred.credential_id|b64encode }}"
|
||||||
|
hx-target="#credentials-section" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Remove this security key?">Remove</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No security keys registered.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Actions</h2>
|
||||||
|
<div id="actions-section">
|
||||||
|
<div>
|
||||||
|
{% if target_user.active %}
|
||||||
|
<button class="btn-secondary" hx-post="/admin/users/{{ target_user.userid }}/deactivate"
|
||||||
|
hx-target="#actions-section" hx-swap="innerHTML">Deactivate user</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn-secondary" hx-post="/admin/users/{{ target_user.userid }}/activate"
|
||||||
|
hx-target="#actions-section" hx-swap="innerHTML">Activate user</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn-secondary" hx-post="/admin/users/{{ target_user.userid }}/invite"
|
||||||
|
hx-target="#invite-result" hx-swap="innerHTML">Generate invite link</button>
|
||||||
|
<div id="invite-result"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn-danger" hx-delete="/admin/users/{{ target_user.userid }}"
|
||||||
|
hx-target="body" hx-confirm="Permanently delete user {{ target_user.username }}? This cannot be undone.">Delete user</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
49
src/porchlight/templates/admin/users.html
Normal file
49
src/porchlight/templates/admin/users.html
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Users — Admin — Porchlight{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<h1>Users</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Create invite</h2>
|
||||||
|
<form hx-post="/admin/invite" hx-target="#invite-status" hx-swap="innerHTML">
|
||||||
|
<div class="admin-search">
|
||||||
|
<input type="text" name="username" placeholder="Username for new invite" required>
|
||||||
|
<button type="submit">Create invite</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="invite-status"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="admin-search">
|
||||||
|
<input type="search" name="q" placeholder="Search by username or email..."
|
||||||
|
hx-get="/admin/users" hx-target="#user-table-body" hx-swap="innerHTML"
|
||||||
|
hx-trigger="input changed delay:300ms, search"
|
||||||
|
hx-include="this" hx-push-url="false"
|
||||||
|
value="{{ query or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="user-table-container">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Groups</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="user-table-body">
|
||||||
|
{% include "admin/_user_rows.html" %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pagination" id="pagination">
|
||||||
|
{% include "admin/_pagination.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<nav class="manage-nav" aria-label="Account management">
|
<nav class="manage-nav" aria-label="Account management">
|
||||||
<a href="/manage/profile" {% if active_page == "profile" %}aria-current="page"{% endif %}>Profile</a>
|
<a href="/manage/profile" {% if active_page == "profile" %}aria-current="page"{% endif %}>Profile</a>
|
||||||
<a href="/manage/credentials" {% if active_page == "credentials" %}aria-current="page"{% endif %}>Credentials</a>
|
<a href="/manage/credentials" {% if active_page == "credentials" %}aria-current="page"{% endif %}>Credentials</a>
|
||||||
|
{% if is_admin %}<a href="/admin/users">Admin</a>{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% block manage_content %}{% endblock %}
|
{% block manage_content %}{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
238
tests/e2e/admin.spec.js
Normal file
238
tests/e2e/admin.spec.js
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}');
|
||||||
|
|
||||||
|
/** Log in as the admin user and land on /manage/credentials. */
|
||||||
|
async function loginAsAdmin(page) {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('#username', fixtures.admin_username);
|
||||||
|
await page.fill('#password', fixtures.admin_password);
|
||||||
|
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
||||||
|
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Log in as a regular (non-admin) user. */
|
||||||
|
async function loginAsRegularUser(page) {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('#username', fixtures.login_username);
|
||||||
|
await page.fill('#password', fixtures.login_password);
|
||||||
|
await page.click('form[hx-post="/login/password"] button[type="submit"]');
|
||||||
|
await page.waitForURL('**/manage/credentials', { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Admin pages', () => {
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 1 & 2. Auth guards
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('Auth guard', () => {
|
||||||
|
test('unauthenticated user visiting /admin/users is redirected to /login', async ({ page }) => {
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.waitForURL('**/login', { timeout: 5000 });
|
||||||
|
expect(page.url()).toContain('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-admin logged-in user gets 403', async ({ page }) => {
|
||||||
|
await loginAsRegularUser(page);
|
||||||
|
const response = await page.goto('/admin/users');
|
||||||
|
expect(response.status()).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 3 & 4 & 5. User list page
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('User list page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has correct page structure', async ({ page }) => {
|
||||||
|
await expect(page.locator('h1')).toHaveText('Users');
|
||||||
|
await expect(page.locator('.admin-table thead th')).toHaveCount(6);
|
||||||
|
await expect(page.locator('input[name="q"]')).toBeVisible();
|
||||||
|
await expect(page.locator('form[hx-post="/admin/invite"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('table headers are correct', async ({ page }) => {
|
||||||
|
const headers = page.locator('.admin-table thead th');
|
||||||
|
await expect(headers.nth(0)).toHaveText('Username');
|
||||||
|
await expect(headers.nth(1)).toHaveText('Name');
|
||||||
|
await expect(headers.nth(2)).toHaveText('Email');
|
||||||
|
await expect(headers.nth(3)).toHaveText('Groups');
|
||||||
|
await expect(headers.nth(4)).toHaveText('Status');
|
||||||
|
await expect(headers.nth(5)).toHaveText('Created');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows seeded users', async ({ page }) => {
|
||||||
|
const tableBody = page.locator('#user-table-body');
|
||||||
|
await expect(tableBody).toContainText('testuser');
|
||||||
|
await expect(tableBody).toContainText('adminuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search filters results', async ({ page }) => {
|
||||||
|
const searchInput = page.locator('input[name="q"]');
|
||||||
|
await searchInput.fill('admin');
|
||||||
|
// Wait for htmx debounce (300ms) and response
|
||||||
|
await expect(page.locator('#user-table-body')).toContainText('adminuser', { timeout: 5000 });
|
||||||
|
// Other users should be filtered out
|
||||||
|
await expect(page.locator('#user-table-body')).not.toContainText('testuser', { timeout: 3000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 6. User detail page structure
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('User detail page', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a user link shows detail page', async ({ page }) => {
|
||||||
|
await page.click(`a:has-text("adminuser")`);
|
||||||
|
await page.waitForURL('**/admin/users/**', { timeout: 5000 });
|
||||||
|
|
||||||
|
await expect(page.locator('h1')).toHaveText('adminuser');
|
||||||
|
// Profile section
|
||||||
|
await expect(page.locator('h2:has-text("Profile")')).toBeVisible();
|
||||||
|
await expect(page.locator('#given_name')).toBeVisible();
|
||||||
|
// Groups section
|
||||||
|
await expect(page.locator('h2:has-text("Groups")')).toBeVisible();
|
||||||
|
await expect(page.locator('#groups')).toBeVisible();
|
||||||
|
// Credentials section
|
||||||
|
await expect(page.locator('h2:has-text("Credentials")')).toBeVisible();
|
||||||
|
// Actions section
|
||||||
|
await expect(page.locator('h2:has-text("Actions")')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 7. Profile update
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('Profile update', () => {
|
||||||
|
test('fill in profile fields and save', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`/admin/users/${fixtures.admin_userid}`);
|
||||||
|
|
||||||
|
await page.fill('#given_name', 'Updated');
|
||||||
|
await page.fill('#family_name', 'Name');
|
||||||
|
await page.fill('#email', 'updated@example.com');
|
||||||
|
|
||||||
|
await page.click('section:has(h2:has-text("Profile")) button[type="submit"]');
|
||||||
|
|
||||||
|
const status = page.locator('#profile-status [role="status"]');
|
||||||
|
await expect(status).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(status).toContainText('Profile updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 8. Groups update
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('Groups update', () => {
|
||||||
|
test('change groups and save', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`/admin/users/${fixtures.admin_userid}`);
|
||||||
|
|
||||||
|
await page.fill('#groups', 'admin, users, editors');
|
||||||
|
await page.click('section:has(h2:has-text("Groups")) button[type="submit"]');
|
||||||
|
|
||||||
|
const status = page.locator('#groups-status [role="status"]');
|
||||||
|
await expect(status).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(status).toContainText('Groups updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 9. Activate/deactivate toggle
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('Activate/deactivate toggle', () => {
|
||||||
|
test('deactivate then activate user', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
// Use a non-admin user for this test
|
||||||
|
await page.goto(`/admin/users/${fixtures.admin_userid}`);
|
||||||
|
|
||||||
|
// Click deactivate
|
||||||
|
await page.click('button:has-text("Deactivate user")');
|
||||||
|
const deactivatedStatus = page.locator('#actions-section [role="status"]');
|
||||||
|
await expect(deactivatedStatus).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(deactivatedStatus).toContainText('User deactivated');
|
||||||
|
|
||||||
|
// Now an activate button should appear
|
||||||
|
await expect(page.locator('button:has-text("Activate user")')).toBeVisible();
|
||||||
|
|
||||||
|
// Click activate
|
||||||
|
await page.click('button:has-text("Activate user")');
|
||||||
|
const activatedStatus = page.locator('#actions-section [role="status"]');
|
||||||
|
await expect(activatedStatus).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(activatedStatus).toContainText('User activated');
|
||||||
|
|
||||||
|
// Deactivate button should reappear
|
||||||
|
await expect(page.locator('button:has-text("Deactivate user")')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 10. Create invite from user list
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('Create invite', () => {
|
||||||
|
test('fill username and submit to get invite URL', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
|
||||||
|
await page.fill('form[hx-post="/admin/invite"] input[name="username"]', 'inviteduser');
|
||||||
|
await page.click('form[hx-post="/admin/invite"] button[type="submit"]');
|
||||||
|
|
||||||
|
const inviteStatus = page.locator('#invite-status');
|
||||||
|
await expect(inviteStatus.locator('.invite-url')).toBeVisible({ timeout: 5000 });
|
||||||
|
const inviteUrl = await inviteStatus.locator('.invite-url').textContent();
|
||||||
|
expect(inviteUrl).toContain('/register/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 11. Re-invite from user detail
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('Re-invite from user detail', () => {
|
||||||
|
test('generate invite link from user detail page', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await page.goto(`/admin/users/${fixtures.admin_userid}`);
|
||||||
|
|
||||||
|
await page.click('button:has-text("Generate invite link")');
|
||||||
|
|
||||||
|
const inviteResult = page.locator('#invite-result');
|
||||||
|
await expect(inviteResult.locator('.invite-url')).toBeVisible({ timeout: 5000 });
|
||||||
|
const inviteUrl = await inviteResult.locator('.invite-url').textContent();
|
||||||
|
expect(inviteUrl).toContain('/register/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 12. Delete user
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
test.describe('Delete user', () => {
|
||||||
|
test('delete a user and verify redirect to user list', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
|
||||||
|
// Use a disposable user seeded specifically for this test
|
||||||
|
await page.goto(`/admin/users/${fixtures.disposable_userid}`);
|
||||||
|
await expect(page.locator('h1')).toHaveText('disposableuser');
|
||||||
|
|
||||||
|
// Set up dialog handler before clicking delete
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.click('button:has-text("Delete user")');
|
||||||
|
|
||||||
|
// htmx processes the HX-Redirect header and navigates to /admin/users
|
||||||
|
await page.waitForURL('**/admin/users', { timeout: 5000 });
|
||||||
|
await expect(page.locator('h1')).toHaveText('Users');
|
||||||
|
|
||||||
|
// The deleted user should no longer appear in the list
|
||||||
|
await expect(page.locator('#user-table-body')).not.toContainText('disposableuser');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -100,6 +100,28 @@ async def seed() -> None:
|
||||||
result["profile_username"] = "profileuser"
|
result["profile_username"] = "profileuser"
|
||||||
result["profile_password"] = "profilepass123"
|
result["profile_password"] = "profilepass123"
|
||||||
|
|
||||||
|
# 6. Admin user for admin page tests
|
||||||
|
admin_user = User(
|
||||||
|
userid="test-user-05",
|
||||||
|
username="adminuser",
|
||||||
|
given_name="Admin",
|
||||||
|
family_name="User",
|
||||||
|
email="admin@example.com",
|
||||||
|
groups=["admin", "users"],
|
||||||
|
)
|
||||||
|
await user_repo.create(admin_user)
|
||||||
|
admin_password_hash = password_service.hash("adminpass123")
|
||||||
|
await cred_repo.create_password(PasswordCredential(user_id=admin_user.userid, password_hash=admin_password_hash))
|
||||||
|
result["admin_username"] = "adminuser"
|
||||||
|
result["admin_password"] = "adminpass123"
|
||||||
|
result["admin_userid"] = "test-user-05"
|
||||||
|
|
||||||
|
# 7. Disposable user for admin delete test (not used by any other tests)
|
||||||
|
disposable_user = User(userid="test-user-06", username="disposableuser", groups=["users"])
|
||||||
|
await user_repo.create(disposable_user)
|
||||||
|
result["disposable_userid"] = "test-user-06"
|
||||||
|
result["disposable_username"] = "disposableuser"
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.close()
|
await db.close()
|
||||||
print(json.dumps(result))
|
print(json.dumps(result))
|
||||||
|
|
|
||||||
0
tests/test_admin/__init__.py
Normal file
0
tests/test_admin/__init__.py
Normal file
53
tests/test_admin/test_admin_guard.py
Normal file
53
tests/test_admin/test_admin_guard.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from porchlight.authn.password import PasswordService
|
||||||
|
from porchlight.models import PasswordCredential, User
|
||||||
|
|
||||||
|
|
||||||
|
async def _login(
|
||||||
|
client: AsyncClient, username: str = "alice", password: str = "testpass", *, groups: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Helper: create user + password credential and log in via POST /login/password."""
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user_repo = app.state.user_repo
|
||||||
|
cred_repo = app.state.credential_repo
|
||||||
|
|
||||||
|
user = await user_repo.get_by_username(username)
|
||||||
|
if user is None:
|
||||||
|
user = User(
|
||||||
|
userid="test-user-01",
|
||||||
|
username=username,
|
||||||
|
groups=groups or [],
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
await user_repo.create(user)
|
||||||
|
|
||||||
|
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
|
||||||
|
existing = await cred_repo.get_password_by_user(user.userid)
|
||||||
|
if existing is None:
|
||||||
|
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password)))
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/login/password",
|
||||||
|
data={"username": username, "password": password},
|
||||||
|
headers={"HX-Request": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_users_redirects_unauthenticated(client: AsyncClient) -> None:
|
||||||
|
response = await client.get("/admin/users", follow_redirects=False)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_users_403_for_non_admin(client: AsyncClient) -> None:
|
||||||
|
await _login(client, username="regularuser", password="password123", groups=["users"])
|
||||||
|
response = await client.get("/admin/users", follow_redirects=False)
|
||||||
|
assert response.status_code == 403
|
||||||
391
tests/test_admin/test_admin_routes.py
Normal file
391
tests/test_admin/test_admin_routes.py
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from porchlight.authn.password import PasswordService
|
||||||
|
from porchlight.models import PasswordCredential, User, WebAuthnCredential
|
||||||
|
|
||||||
|
|
||||||
|
async def _login(
|
||||||
|
client: AsyncClient,
|
||||||
|
username: str = "admin",
|
||||||
|
password: str = "adminpass",
|
||||||
|
*,
|
||||||
|
userid: str = "admin-user-01",
|
||||||
|
groups: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Helper: create user + password credential and log in via POST /login/password."""
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user_repo = app.state.user_repo
|
||||||
|
cred_repo = app.state.credential_repo
|
||||||
|
|
||||||
|
user = await user_repo.get_by_username(username)
|
||||||
|
if user is None:
|
||||||
|
user = User(
|
||||||
|
userid=userid,
|
||||||
|
username=username,
|
||||||
|
groups=groups if groups is not None else ["admin"],
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
await user_repo.create(user)
|
||||||
|
|
||||||
|
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
|
||||||
|
existing = await cred_repo.get_password_by_user(user.userid)
|
||||||
|
if existing is None:
|
||||||
|
await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password)))
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
"/login/password",
|
||||||
|
data={"username": username, "password": password},
|
||||||
|
headers={"HX-Request": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_target_user(
|
||||||
|
client: AsyncClient,
|
||||||
|
*,
|
||||||
|
userid: str = "target-user-01",
|
||||||
|
username: str = "bob",
|
||||||
|
email: str | None = None,
|
||||||
|
groups: list[str] | None = None,
|
||||||
|
active: bool = True,
|
||||||
|
) -> User:
|
||||||
|
"""Helper: create a target user in the database (does NOT log in)."""
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user_repo = app.state.user_repo
|
||||||
|
user = User(
|
||||||
|
userid=userid,
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
groups=groups or [],
|
||||||
|
active=active,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
return await user_repo.create(user)
|
||||||
|
|
||||||
|
|
||||||
|
# --- User list ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_users_list_returns_html(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
response = await client.get("/admin/users")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_users_list_shows_users(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
await _create_target_user(client, username="bob")
|
||||||
|
response = await client.get("/admin/users")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "bob" in response.text
|
||||||
|
assert "admin" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_users_list_search(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
await _create_target_user(client, userid="target-user-01", username="bob")
|
||||||
|
await _create_target_user(client, userid="target-user-02", username="carol")
|
||||||
|
response = await client.get("/admin/users?q=bob")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "bob" in response.text
|
||||||
|
assert "carol" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_users_list_htmx_search_returns_partial(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
await _create_target_user(client, username="bob")
|
||||||
|
response = await client.get(
|
||||||
|
"/admin/users?q=bob",
|
||||||
|
headers={"HX-Request": "true", "HX-Trigger-Name": "q"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Partial should contain user row data but not the full page layout
|
||||||
|
assert "bob" in response.text
|
||||||
|
assert "<thead>" not in response.text
|
||||||
|
assert "<html" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
# --- User detail ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_detail_returns_html(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
response = await client.get(f"/admin/users/{target.userid}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
assert "bob" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_detail_404_for_nonexistent(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
response = await client.get("/admin/users/nonexistent-id")
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "not found" in response.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Profile update ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_profile(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
response = await client.post(
|
||||||
|
f"/admin/users/{target.userid}/profile",
|
||||||
|
data={
|
||||||
|
"given_name": "Bob",
|
||||||
|
"family_name": "Smith",
|
||||||
|
"preferred_username": "bobby",
|
||||||
|
"email": "bob@example.com",
|
||||||
|
"phone_number": "+1234567890",
|
||||||
|
"picture": "https://example.com/bob.jpg",
|
||||||
|
"locale": "en-US",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Profile updated" in response.text
|
||||||
|
|
||||||
|
# Verify user was actually updated
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user = await app.state.user_repo.get_by_userid(target.userid)
|
||||||
|
assert user is not None
|
||||||
|
assert user.given_name == "Bob"
|
||||||
|
assert user.family_name == "Smith"
|
||||||
|
assert user.email == "bob@example.com"
|
||||||
|
assert user.picture == "https://example.com/bob.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_profile_invalid_email(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
response = await client.post(
|
||||||
|
f"/admin/users/{target.userid}/profile",
|
||||||
|
data={"email": "not-an-email"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Invalid email" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_profile_invalid_picture_url(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
response = await client.post(
|
||||||
|
f"/admin/users/{target.userid}/profile",
|
||||||
|
data={"picture": "not-a-url"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Picture URL must be a valid HTTP or HTTPS URL" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_profile_404_for_nonexistent(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/admin/users/nonexistent-id/profile",
|
||||||
|
data={"given_name": "Ghost"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "not found" in response.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Groups update ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_groups(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
response = await client.post(
|
||||||
|
f"/admin/users/{target.userid}/groups",
|
||||||
|
data={"groups": "admin, users, editors"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Groups updated" in response.text
|
||||||
|
|
||||||
|
# Verify groups were actually updated
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user = await app.state.user_repo.get_by_userid(target.userid)
|
||||||
|
assert user is not None
|
||||||
|
assert set(user.groups) == {"admin", "users", "editors"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Activate / deactivate ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activate_user(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob", active=False)
|
||||||
|
response = await client.post(f"/admin/users/{target.userid}/activate")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "User activated" in response.text
|
||||||
|
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user = await app.state.user_repo.get_by_userid(target.userid)
|
||||||
|
assert user is not None
|
||||||
|
assert user.active is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deactivate_user(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob", active=True)
|
||||||
|
response = await client.post(f"/admin/users/{target.userid}/deactivate")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "User deactivated" in response.text
|
||||||
|
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user = await app.state.user_repo.get_by_userid(target.userid)
|
||||||
|
assert user is not None
|
||||||
|
assert user.active is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Delete user ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_user(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
response = await client.request("DELETE", f"/admin/users/{target.userid}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "User deleted" in response.text
|
||||||
|
assert response.headers.get("hx-redirect") == "/admin/users"
|
||||||
|
|
||||||
|
# Verify user was actually deleted
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user = await app.state.user_repo.get_by_userid(target.userid)
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_user_self_prevention(client: AsyncClient) -> None:
|
||||||
|
await _login(client, userid="admin-user-01")
|
||||||
|
# Try to delete ourselves
|
||||||
|
response = await client.request("DELETE", "/admin/users/admin-user-01")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Cannot delete your own account" in response.text
|
||||||
|
|
||||||
|
# Verify admin user was NOT deleted
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
user = await app.state.user_repo.get_by_userid("admin-user-01")
|
||||||
|
assert user is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Delete credentials ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_password_credential(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
|
||||||
|
# Create a password credential for the target user
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
cred_repo = app.state.credential_repo
|
||||||
|
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
|
||||||
|
await cred_repo.create_password(PasswordCredential(user_id=target.userid, password_hash=svc.hash("bobpass")))
|
||||||
|
|
||||||
|
response = await client.request("DELETE", f"/admin/users/{target.userid}/credentials/password")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
# Verify password was deleted
|
||||||
|
pw = await cred_repo.get_password_by_user(target.userid)
|
||||||
|
assert pw is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_webauthn_credential(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
|
||||||
|
# Create a webauthn credential for the target user
|
||||||
|
app = client._transport.app # type: ignore[union-attr]
|
||||||
|
cred_repo = app.state.credential_repo
|
||||||
|
credential_id = b"\x01\x02\x03\x04\x05\x06\x07\x08"
|
||||||
|
await cred_repo.create_webauthn(
|
||||||
|
WebAuthnCredential(
|
||||||
|
user_id=target.userid,
|
||||||
|
credential_id=credential_id,
|
||||||
|
public_key=b"\x00" * 32,
|
||||||
|
sign_count=0,
|
||||||
|
device_name="test-key",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# URL uses base64url without padding
|
||||||
|
from base64 import urlsafe_b64encode
|
||||||
|
|
||||||
|
credential_id_b64 = urlsafe_b64encode(credential_id).decode().rstrip("=")
|
||||||
|
|
||||||
|
response = await client.request("DELETE", f"/admin/users/{target.userid}/credentials/webauthn/{credential_id_b64}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
# Verify credential was deleted
|
||||||
|
creds = await cred_repo.get_webauthn_by_user(target.userid)
|
||||||
|
assert len(creds) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- Create invite ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_invite(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/admin/invite",
|
||||||
|
data={"username": "newuser"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Invite created" in response.text
|
||||||
|
assert "newuser" in response.text
|
||||||
|
assert "/register/" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_invite_empty_username(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
response = await client.post(
|
||||||
|
"/admin/invite",
|
||||||
|
data={"username": " "},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Username is required" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
# --- Re-invite ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reinvite_user(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
target = await _create_target_user(client, username="bob")
|
||||||
|
response = await client.post(f"/admin/users/{target.userid}/invite")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Invite link generated" in response.text
|
||||||
|
assert "/register/" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reinvite_user_404_for_nonexistent(client: AsyncClient) -> None:
|
||||||
|
await _login(client)
|
||||||
|
response = await client.post("/admin/users/nonexistent-id/invite")
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "not found" in response.text.lower()
|
||||||
|
|
@ -159,6 +159,49 @@ async def test_create_duplicate_username(user_repo: SQLiteUserRepository) -> Non
|
||||||
await user_repo.create(_make_user(userid="different-id", username="alice"))
|
await user_repo.create(_make_user(userid="different-id", username="alice"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_users_by_username(user_repo: SQLiteUserRepository) -> None:
|
||||||
|
await user_repo.create(_make_user(userid="id-1", username="sample_user"))
|
||||||
|
results = await user_repo.search_users("sample", offset=0, limit=100)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].userid == "id-1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_users_by_email(user_repo: SQLiteUserRepository) -> None:
|
||||||
|
await user_repo.create(_make_user(email="alice@example.com"))
|
||||||
|
results = await user_repo.search_users("alice", offset=0, limit=100)
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_users_no_match(user_repo: SQLiteUserRepository) -> None:
|
||||||
|
await user_repo.create(_make_user())
|
||||||
|
results = await user_repo.search_users("nonexistent", offset=0, limit=100)
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_users_pagination(user_repo: SQLiteUserRepository) -> None:
|
||||||
|
for i in range(5):
|
||||||
|
await user_repo.create(_make_user(userid=f"id-{i}", username=f"user{i}", groups=["users"]))
|
||||||
|
page1 = await user_repo.search_users("user", offset=0, limit=2)
|
||||||
|
page2 = await user_repo.search_users("user", offset=2, limit=2)
|
||||||
|
assert len(page1) == 2
|
||||||
|
assert len(page2) == 2
|
||||||
|
assert page1[0].username != page2[0].username
|
||||||
|
|
||||||
|
|
||||||
|
async def test_count_users_no_query(user_repo: SQLiteUserRepository) -> None:
|
||||||
|
await user_repo.create(_make_user())
|
||||||
|
count = await user_repo.count_users()
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_count_users_with_query(user_repo: SQLiteUserRepository) -> None:
|
||||||
|
await user_repo.create(_make_user())
|
||||||
|
count = await user_repo.count_users(query="alice")
|
||||||
|
assert count == 1
|
||||||
|
count = await user_repo.count_users(query="nonexistent")
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_roundtrip_preserves_all_fields(user_repo: SQLiteUserRepository) -> None:
|
async def test_roundtrip_preserves_all_fields(user_repo: SQLiteUserRepository) -> None:
|
||||||
user = _make_user(
|
user = _make_user(
|
||||||
preferred_username="ally",
|
preferred_username="ally",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue