From 4db7f2e9a6c5134879923cc6fdaddf5d50339502 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 29 Aug 2025 21:54:51 -0600 Subject: [PATCH] Almost usable admin panel --- frontend/src/admin/AdminApp.vue | 448 +++++++++++++++--- frontend/src/admin/main.js | 5 +- frontend/src/components/CredentialList.vue | 84 ++++ .../src/components/RegistrationLinkModal.vue | 87 ++++ passkey/db/__init__.py | 4 + passkey/db/sql.py | 19 + passkey/fastapi/api.py | 143 +++++- 7 files changed, 713 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/CredentialList.vue create mode 100644 frontend/src/components/RegistrationLinkModal.vue diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index 81c6078..545fbf5 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -1,17 +1,46 @@ diff --git a/frontend/src/admin/main.js b/frontend/src/admin/main.js index f8dafd2..9b4a85c 100644 --- a/frontend/src/admin/main.js +++ b/frontend/src/admin/main.js @@ -1,6 +1,9 @@ import '../assets/style.css' import { createApp } from 'vue' +import { createPinia } from 'pinia' import AdminApp from './AdminApp.vue' -createApp(AdminApp).mount('#admin-app') +const app = createApp(AdminApp) +app.use(createPinia()) +app.mount('#admin-app') diff --git a/frontend/src/components/CredentialList.vue b/frontend/src/components/CredentialList.vue new file mode 100644 index 0000000..af7d5f1 --- /dev/null +++ b/frontend/src/components/CredentialList.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend/src/components/RegistrationLinkModal.vue b/frontend/src/components/RegistrationLinkModal.vue new file mode 100644 index 0000000..23f995c --- /dev/null +++ b/frontend/src/components/RegistrationLinkModal.vue @@ -0,0 +1,87 @@ + + + + diff --git a/passkey/db/__init__.py b/passkey/db/__init__.py index a4500be..0a53b2b 100644 --- a/passkey/db/__init__.py +++ b/passkey/db/__init__.py @@ -205,6 +205,10 @@ class DatabaseInterface(ABC): async def get_organization_users(self, org_id: str) -> list[tuple[User, str]]: """Get all users in an organization with their roles.""" + @abstractmethod + async def get_roles_by_organization(self, org_id: str) -> list[Role]: + """List roles belonging to an organization.""" + @abstractmethod async def get_user_role_in_organization( self, user_uuid: UUID, org_id: str diff --git a/passkey/db/sql.py b/passkey/db/sql.py index 9b3ef96..d9bf86c 100644 --- a/passkey/db/sql.py +++ b/passkey/db/sql.py @@ -864,6 +864,25 @@ class DB(DatabaseInterface): r_dc.permissions = [row[0] for row in perms_result.fetchall()] return r_dc + async def get_roles_by_organization(self, org_id: str) -> list[Role]: + async with self.session() as session: + org_uuid = UUID(org_id) + result = await session.execute( + select(RoleModel).where(RoleModel.org_uuid == org_uuid.bytes) + ) + role_models = result.scalars().all() + roles: list[Role] = [] + for rm in role_models: + r_dc = rm.as_dataclass() + perms_result = await session.execute( + select(RolePermission.permission_id).where( + RolePermission.role_uuid == rm.uuid + ) + ) + r_dc.permissions = [row[0] for row in perms_result.fetchall()] + roles.append(r_dc) + return roles + async def add_permission_to_organization( self, org_id: str, permission_id: str ) -> None: diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index 9236e33..c2c9d91 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -16,8 +16,10 @@ from fastapi.security import HTTPBearer from passkey.util import passphrase from .. import aaguid -from ..authsession import delete_credential, get_reset, get_session +from ..authsession import delete_credential, expires, get_reset, get_session from ..globals import db +from ..globals import passkey as global_passkey +from ..util import tokens from ..util.tokens import session_key from . import session @@ -321,6 +323,42 @@ def register_api_routes(app: FastAPI): await db.instance.update_role(updated) return {"status": "ok"} + @app.post("/auth/admin/orgs/{org_uuid}/users") + async def admin_create_user( + org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + ): + """Create a new user within an organization. + + Body parameters: + - display_name: str (required) + - role: str (required) display name of existing role in that org + """ + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)): + raise ValueError("Insufficient permissions") + display_name = payload.get("display_name") + role_name = payload.get("role") + if not display_name or not role_name: + raise ValueError("display_name and role are required") + # Validate role exists in org + from ..db import User as UserDC # local import to avoid cycles + roles = await db.instance.get_roles_by_organization(str(org_uuid)) + role_obj = next((r for r in roles if r.display_name == role_name), None) + if not role_obj: + raise ValueError("Role not found in organization") + # Create user + user_uuid = uuid4() + user = UserDC( + uuid=user_uuid, + display_name=display_name, + role_uuid=role_obj.uuid, + visits=0, + created_at=None, + last_seen=None, + ) + await db.instance.create_user(user) + return {"uuid": str(user_uuid)} + @app.delete("/auth/admin/roles/{role_uuid}") async def admin_delete_role(role_uuid: UUID, auth=Cookie(None)): ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) @@ -330,6 +368,109 @@ def register_api_routes(app: FastAPI): await db.instance.delete_role(role_uuid) return {"status": "ok"} + # -------------------- Admin API: Users (role management) -------------------- + + @app.put("/auth/admin/orgs/{org_uuid}/users/{user_uuid}/role") + async def admin_update_user_role( + org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + ): + """Change a user's role within their organization. + + Body: {"role": "New Role Display Name"} + Only global admins or admins of the organization can perform this. + """ + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)): + raise ValueError("Insufficient permissions") + new_role = payload.get("role") + if not new_role: + raise ValueError("role is required") + # Verify user belongs to this org + try: + user_org, _current_role = await db.instance.get_user_organization(user_uuid) + except ValueError: + raise ValueError("User not found") + if user_org.uuid != org_uuid: + raise ValueError("User does not belong to this organization") + # Ensure role exists in org and update + roles = await db.instance.get_roles_by_organization(str(org_uuid)) + if not any(r.display_name == new_role for r in roles): + raise ValueError("Role not found in organization") + await db.instance.update_user_role_in_organization(user_uuid, new_role) + return {"status": "ok"} + + @app.post("/auth/admin/users/{user_uuid}/create-link") + async def admin_create_user_registration_link(user_uuid: UUID, auth=Cookie(None)): + """Create a device registration/reset link for a specific user (admin only). + + Returns JSON: {"url": str, "expires": iso8601} + """ + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + # Ensure user exists and fetch their org + try: + user_org, _role_name = await db.instance.get_user_organization(user_uuid) + except ValueError: + raise HTTPException(status_code=404, detail="User not found") + if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + # Generate human-readable reset token and store as session with reset key + token = passphrase.generate() + await db.instance.create_session( + user_uuid=user_uuid, + key=tokens.reset_key(token), + expires=expires(), + info={"type": "device addition", "created_by_admin": True}, + ) + origin = global_passkey.instance.origin + url = f"{origin}/auth/{token}" + return {"url": url, "expires": expires().isoformat()} + + @app.get("/auth/admin/users/{user_uuid}") + async def admin_get_user_detail(user_uuid: UUID, auth=Cookie(None)): + """Get detailed information about a user (admin only).""" + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + try: + user_org, role_name = await db.instance.get_user_organization(user_uuid) + except ValueError: + raise HTTPException(status_code=404, detail="User not found") + if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)): + raise HTTPException(status_code=403, detail="Insufficient permissions") + user = await db.instance.get_user_by_uuid(user_uuid) + # Gather credentials + cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid) + creds: list[dict] = [] + aaguids: set[str] = set() + for cid in cred_ids: + try: + c = await db.instance.get_credential_by_id(cid) + except ValueError: + continue + aaguid_str = str(c.aaguid) + aaguids.add(aaguid_str) + creds.append( + { + "credential_uuid": str(c.uuid), + "aaguid": aaguid_str, + "created_at": c.created_at.isoformat(), + "last_used": c.last_used.isoformat() if c.last_used else None, + "last_verified": c.last_verified.isoformat() if c.last_verified else None, + "sign_count": c.sign_count, + } + ) + from .. import aaguid as aaguid_mod + aaguid_info = aaguid_mod.filter(aaguids) + return { + "display_name": user.display_name, + "org": {"display_name": user_org.display_name}, + "role": role_name, + "visits": user.visits, + "created_at": user.created_at.isoformat() if user.created_at else None, + "last_seen": user.last_seen.isoformat() if user.last_seen else None, + "credentials": creds, + "aaguid_info": aaguid_info, + } + # -------------------- Admin API: Permissions (global) -------------------- @app.get("/auth/admin/permissions")