Renaming of users in registration, profile and admin app.
This commit is contained in:
		| @@ -364,6 +364,32 @@ async function toggleRolePermission(role, permId, checked) { | ||||
| function openDialog(type, data) { dialog.value = { type, data, busy: false, error: '' } } | ||||
| function closeDialog() { dialog.value = { type: null, data: null, busy: false, error: '' } } | ||||
|  | ||||
| // Admin user rename | ||||
| const editingUserName = ref(false) | ||||
| const editUserNameValue = ref('') | ||||
| const editUserNameValid = computed(()=> editUserNameValue.value.trim().length > 0 && editUserNameValue.value.trim().length <= 64) | ||||
| function beginEditUserName() { | ||||
|   if (!selectedUser.value) return | ||||
|   editingUserName.value = true | ||||
|   editUserNameValue.value = userDetail.value?.display_name || selectedUser.value.display_name || '' | ||||
| } | ||||
| function cancelEditUserName() { editingUserName.value = false } | ||||
| async function submitEditUserName() { | ||||
|   if (!editingUserName.value || !editUserNameValid.value) return | ||||
|   try { | ||||
|     const res = await fetch(`/auth/admin/users/${selectedUser.value.uuid}/display-name`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: editUserNameValue.value.trim() }) }) | ||||
|     const data = await res.json(); if (!res.ok || data.detail) throw new Error(data.detail || 'Rename failed') | ||||
|     editingUserName.value = false | ||||
|     await loadOrgs() | ||||
|     const r = await fetch(`/auth/admin/users/${selectedUser.value.uuid}`) | ||||
|     const jd = await r.json(); if (!r.ok || jd.detail) throw new Error(jd.detail || 'Reload failed') | ||||
|     userDetail.value = jd | ||||
|     authStore.showMessage('User renamed', 'success', 1500) | ||||
|   } catch (e) { | ||||
|     authStore.showMessage(e.message || 'Rename failed') | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function submitDialog() { | ||||
|   if (!dialog.value.type || dialog.value.busy) return | ||||
|   dialog.value.busy = true; dialog.value.error = '' | ||||
| @@ -458,7 +484,14 @@ async function submitDialog() { | ||||
|  | ||||
|         <!-- User Detail Page --> | ||||
|         <div v-if="selectedUser" class="card user-detail"> | ||||
|           <h2 class="user-title"><span>{{ userDetail?.display_name || selectedUser.display_name }}</span></h2> | ||||
|           <h2 class="user-title"> | ||||
|             <span v-if="!editingUserName">{{ userDetail?.display_name || selectedUser.display_name }} <button class="icon-btn" @click="beginEditUserName" title="Rename user">✏️</button></span> | ||||
|             <span v-else> | ||||
|               <input v-model="editUserNameValue" maxlength="64" @keyup.enter="submitEditUserName" /> | ||||
|               <button class="icon-btn" @click="submitEditUserName" :disabled="!editUserNameValid">💾</button> | ||||
|               <button class="icon-btn" @click="cancelEditUserName">✖</button> | ||||
|             </span> | ||||
|           </h2> | ||||
|           <div v-if="userDetail && !userDetail.error" class="user-meta"> | ||||
|             <p class="small">Organization: {{ userDetail.org.display_name }}</p> | ||||
|             <p class="small">Role: {{ userDetail.role }}</p> | ||||
|   | ||||
| @@ -3,7 +3,15 @@ | ||||
|     <div class="view active"> | ||||
|   <h1>👋 Welcome! <a v-if="isAdmin" href="/auth/admin/" class="admin-link" title="Admin Console">Admin</a></h1> | ||||
|       <div v-if="authStore.userInfo?.user" class="user-info"> | ||||
|         <h3>👤 {{ authStore.userInfo.user.user_name }}</h3> | ||||
|         <h3> | ||||
|           👤 | ||||
|           <template v-if="!editingName">{{ authStore.userInfo.user.user_name }} <button class="mini-btn" @click="startEdit" title="Edit name">✏️</button></template> | ||||
|           <template v-else> | ||||
|             <input v-model="newName" :disabled="authStore.isLoading" maxlength="64" @keyup.enter="saveName" /> | ||||
|             <button class="mini-btn" @click="saveName" :disabled="!validName || authStore.isLoading">💾</button> | ||||
|             <button class="mini-btn" @click="cancelEdit" :disabled="authStore.isLoading">✖</button> | ||||
|           </template> | ||||
|         </h3> | ||||
|         <span><strong>Visits:</strong></span> | ||||
|         <span>{{ authStore.userInfo.user.visits || 0 }}</span> | ||||
|         <span><strong>Registered:</strong></span> | ||||
| @@ -147,6 +155,25 @@ const logout = async () => { | ||||
| } | ||||
|  | ||||
| const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) | ||||
|  | ||||
| // Name editing state & actions | ||||
| const editingName = ref(false) | ||||
| const newName = ref('') | ||||
| const validName = computed(() => newName.value.trim().length > 0 && newName.value.trim().length <= 64) | ||||
| function startEdit() { editingName.value = true; newName.value = authStore.userInfo?.user?.user_name || '' } | ||||
| function cancelEdit() { editingName.value = false } | ||||
| async function saveName() { | ||||
|   if (!validName.value) return | ||||
|   try { | ||||
|     authStore.isLoading = true | ||||
|     const res = await fetch('/auth/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: newName.value.trim() }) }) | ||||
|     const data = await res.json(); if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') | ||||
|     await authStore.loadUserInfo() | ||||
|     editingName.value = false | ||||
|     authStore.showMessage('Name updated', 'success', 1500) | ||||
|   } catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } | ||||
|   finally { authStore.isLoading = false } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| @@ -161,6 +188,7 @@ const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authSto | ||||
| .user-info span { | ||||
|   text-align: left; | ||||
| } | ||||
| .mini-btn { font-size: 0.7em; margin-left: 0.3em; } | ||||
| </style> | ||||
|  | ||||
| <style scoped> | ||||
|   | ||||
| @@ -2,8 +2,17 @@ | ||||
|   <div class="container"> | ||||
|     <div class="view active"> | ||||
|       <h1>🔑 Add New Credential</h1> | ||||
|       <h3>👤 {{ authStore.userInfo?.user?.user_name }}</h3> | ||||
|       <!-- TODO: allow editing name <input type="text" v-model="user_name" required :disabled="authStore.isLoading"> --> | ||||
|       <label class="name-edit"> | ||||
|         <span>👤 Name:</span> | ||||
|         <input | ||||
|           type="text" | ||||
|           v-model="user_name" | ||||
|           :placeholder="authStore.userInfo?.user?.user_name || 'Your name'" | ||||
|           :disabled="authStore.isLoading" | ||||
|           maxlength="64" | ||||
|           @keyup.enter="register" | ||||
|         /> | ||||
|       </label> | ||||
|       <p>Proceed to complete {{authStore.userInfo?.session_type}}:</p> | ||||
|       <button | ||||
|         class="btn-primary" | ||||
| @@ -19,15 +28,26 @@ | ||||
| <script setup> | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import passkey from '@/utils/passkey' | ||||
| import { ref, watchEffect } from 'vue' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const user_name = ref('') | ||||
|  | ||||
| // Initialize local name from store (once loaded) | ||||
| watchEffect(() => { | ||||
|   if (!user_name.value && authStore.userInfo?.user?.user_name) { | ||||
|     user_name.value = authStore.userInfo.user.user_name | ||||
|   } | ||||
| }) | ||||
|  | ||||
| async function register() { | ||||
|   authStore.isLoading = true | ||||
|   authStore.showMessage('Starting registration...', 'info') | ||||
|  | ||||
|   try { | ||||
|   const result = await passkey.register(authStore.resetToken) | ||||
|   const trimmed = (user_name.value || '').trim() | ||||
|   const nameToSend = trimmed.length ? trimmed : null | ||||
|   const result = await passkey.register(authStore.resetToken, nameToSend) | ||||
|   console.log("Result", result) | ||||
|   await authStore.setSessionCookie(result.session_token) | ||||
|   // resetToken cleared by setSessionCookie; ensure again | ||||
|   | ||||
| @@ -1,8 +1,12 @@ | ||||
| import { startRegistration, startAuthentication } from '@simplewebauthn/browser' | ||||
| import aWebSocket from '@/utils/awaitable-websocket' | ||||
|  | ||||
| export async function register(resetToken = null) { | ||||
|   const url = resetToken ? `/auth/ws/register?reset=${encodeURIComponent(resetToken)}` : "/auth/ws/register" | ||||
| export async function register(resetToken = null, displayName = null) { | ||||
|   let params = [] | ||||
|   if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`) | ||||
|   if (displayName) params.push(`name=${encodeURIComponent(displayName)}`) | ||||
|   const qs = params.length ? `?${params.join('&')}` : '' | ||||
|   const url = `/auth/ws/register${qs}` | ||||
|   const ws = await aWebSocket(url) | ||||
|   try { | ||||
|     const optionsJSON = await ws.receive_json() | ||||
|   | ||||
| @@ -100,6 +100,10 @@ class DatabaseInterface(ABC): | ||||
|     async def create_user(self, user: User) -> None: | ||||
|         """Create a new user.""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def update_user_display_name(self, user_uuid: UUID, display_name: str) -> None: | ||||
|         """Update a user's display name.""" | ||||
|  | ||||
|     # Role operations | ||||
|     @abstractmethod | ||||
|     async def create_role(self, role: Role) -> None: | ||||
| @@ -312,6 +316,27 @@ class DatabaseInterface(ABC): | ||||
|     async def get_session_context(self, session_key: bytes) -> SessionContext | None: | ||||
|         """Get complete session context including user, organization, role, and permissions.""" | ||||
|  | ||||
|         # Combined atomic operations | ||||
|         @abstractmethod | ||||
|         async def create_credential_session( | ||||
|             self, | ||||
|             user_uuid: UUID, | ||||
|             credential: Credential, | ||||
|             reset_key: bytes | None, | ||||
|             session_key: bytes, | ||||
|             session_expires: datetime, | ||||
|             session_info: dict, | ||||
|             display_name: str | None = None, | ||||
|         ) -> None: | ||||
|             """Atomically add a credential and create a session. | ||||
|  | ||||
|             Steps (single transaction): | ||||
|                 1. Insert credential | ||||
|                 2. Optionally delete old session (e.g. reset token) if provided | ||||
|                 3. Optionally update user's display name | ||||
|                 4. Insert new session referencing the credential | ||||
|             """ | ||||
|  | ||||
|  | ||||
| __all__ = [ | ||||
|     "User", | ||||
|   | ||||
| @@ -271,6 +271,17 @@ class DB(DatabaseInterface): | ||||
|         async with self.session() as session: | ||||
|             session.add(UserModel.from_dataclass(user)) | ||||
|  | ||||
|     async def update_user_display_name(self, user_uuid: UUID, display_name: str) -> None: | ||||
|         async with self.session() as session: | ||||
|             stmt = ( | ||||
|                 update(UserModel) | ||||
|                 .where(UserModel.uuid == user_uuid.bytes) | ||||
|                 .values(display_name=display_name) | ||||
|             ) | ||||
|             result = await session.execute(stmt) | ||||
|             if result.rowcount == 0:  # type: ignore[attr-defined] | ||||
|                 raise ValueError("User not found") | ||||
|  | ||||
|     async def create_role(self, role: Role) -> None: | ||||
|         async with self.session() as session: | ||||
|             # Create role record | ||||
| @@ -389,6 +400,55 @@ class DB(DatabaseInterface): | ||||
|             ) | ||||
|             session.add(credential_model) | ||||
|  | ||||
|     async def create_credential_session( | ||||
|         self, | ||||
|         user_uuid: UUID, | ||||
|         credential: Credential, | ||||
|         reset_key: bytes | None, | ||||
|         session_key: bytes, | ||||
|         session_expires: datetime, | ||||
|         session_info: dict, | ||||
|         display_name: str | None = None, | ||||
|     ) -> None: | ||||
|         """Atomic credential + (optional old session delete) + (optional rename) + new session.""" | ||||
|         async with self.session() as session: | ||||
|             # Insert credential | ||||
|             session.add( | ||||
|                 CredentialModel( | ||||
|                     uuid=credential.uuid.bytes, | ||||
|                     credential_id=credential.credential_id, | ||||
|                     user_uuid=credential.user_uuid.bytes, | ||||
|                     aaguid=credential.aaguid.bytes, | ||||
|                     public_key=credential.public_key, | ||||
|                     sign_count=credential.sign_count, | ||||
|                     created_at=credential.created_at, | ||||
|                     last_used=credential.last_used, | ||||
|                     last_verified=credential.last_verified, | ||||
|                 ) | ||||
|             ) | ||||
|             # Delete old session if provided | ||||
|             if reset_key: | ||||
|                 await session.execute( | ||||
|                     delete(SessionModel).where(SessionModel.key == reset_key) | ||||
|                 ) | ||||
|             # Optional rename | ||||
|             if display_name: | ||||
|                 await session.execute( | ||||
|                     update(UserModel) | ||||
|                     .where(UserModel.uuid == user_uuid.bytes) | ||||
|                     .values(display_name=display_name) | ||||
|                 ) | ||||
|             # New session | ||||
|             session.add( | ||||
|                 SessionModel( | ||||
|                     key=session_key, | ||||
|                     user_uuid=user_uuid.bytes, | ||||
|                     credential_uuid=credential.uuid.bytes, | ||||
|                     expires=session_expires, | ||||
|                     info=session_info, | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|     async def delete_credential(self, uuid: UUID, user_uuid: UUID) -> None: | ||||
|         async with self.session() as session: | ||||
|             stmt = ( | ||||
|   | ||||
| @@ -487,6 +487,40 @@ def register_api_routes(app: FastAPI): | ||||
|             "aaguid_info": aaguid_info, | ||||
|         } | ||||
|  | ||||
|     @app.put("/auth/user/display-name") | ||||
|     async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)): | ||||
|         """Authenticated user updates their own display name.""" | ||||
|         if not auth: | ||||
|             raise HTTPException(status_code=401, detail="Authentication Required") | ||||
|         s = await get_session(auth) | ||||
|         new_name = (payload.get("display_name") or "").strip() | ||||
|         if not new_name: | ||||
|             raise HTTPException(status_code=400, detail="display_name required") | ||||
|         if len(new_name) > 64: | ||||
|             raise HTTPException(status_code=400, detail="display_name too long") | ||||
|         await db.instance.update_user_display_name(s.user_uuid, new_name) | ||||
|         return {"status": "ok"} | ||||
|  | ||||
|     @app.put("/auth/admin/users/{user_uuid}/display-name") | ||||
|     async def admin_update_user_display_name( | ||||
|         user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
|     ): | ||||
|         """Admin updates a user's display name.""" | ||||
|         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") | ||||
|         new_name = (payload.get("display_name") or "").strip() | ||||
|         if not new_name: | ||||
|             raise HTTPException(status_code=400, detail="display_name required") | ||||
|         if len(new_name) > 64: | ||||
|             raise HTTPException(status_code=400, detail="display_name too long") | ||||
|         await db.instance.update_user_display_name(user_uuid, new_name) | ||||
|         return {"status": "ok"} | ||||
|  | ||||
|     # Admin API: Permissions (global) | ||||
|  | ||||
|     @app.get("/auth/admin/permissions") | ||||
|   | ||||
| @@ -5,9 +5,10 @@ from uuid import UUID | ||||
| from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect | ||||
| from webauthn.helpers.exceptions import InvalidAuthenticationResponse | ||||
|  | ||||
| from ..authsession import create_session, get_reset, get_session | ||||
| from ..authsession import create_session, expires, get_reset, get_session | ||||
| from ..globals import db, passkey | ||||
| from ..util import passphrase | ||||
| from ..util.tokens import create_token, session_key | ||||
| from .session import infodict | ||||
|  | ||||
|  | ||||
| @@ -55,7 +56,7 @@ async def register_chat( | ||||
| @app.websocket("/register") | ||||
| @websocket_error_handler | ||||
| async def websocket_register_add( | ||||
|     ws: WebSocket, reset: str | None = None, auth=Cookie(None) | ||||
|     ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None) | ||||
| ): | ||||
|     """Register a new credential for an existing user. | ||||
|  | ||||
| @@ -64,11 +65,9 @@ async def websocket_register_add( | ||||
|     - Reset token supplied as ?reset=... (auth cookie ignored) | ||||
|     """ | ||||
|     origin = ws.headers["origin"] | ||||
|     is_reset = False | ||||
|     if reset is not None: | ||||
|         if not passphrase.is_well_formed(reset): | ||||
|             raise ValueError("Invalid reset token") | ||||
|         is_reset = True | ||||
|         s = await get_reset(reset) | ||||
|     else: | ||||
|         if not auth: | ||||
| @@ -76,23 +75,30 @@ async def websocket_register_add( | ||||
|         s = await get_session(auth) | ||||
|     user_uuid = s.user_uuid | ||||
|  | ||||
|     # Get user information to get the user_name | ||||
|     # Get user information and determine effective user_name for this registration | ||||
|     user = await db.instance.get_user_by_uuid(user_uuid) | ||||
|     user_name = user.display_name | ||||
|     if name is not None: | ||||
|         stripped = name.strip() | ||||
|         if stripped: | ||||
|             user_name = stripped | ||||
|     challenge_ids = await db.instance.get_credentials_by_user_uuid(user_uuid) | ||||
|  | ||||
|     # WebAuthn registration | ||||
|     credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin) | ||||
|     # IMPORTANT: Insert the credential before creating a session that references it | ||||
|     # to satisfy the sessions.credential_uuid foreign key (now enforced). | ||||
|     await db.instance.create_credential(credential) | ||||
|  | ||||
|     if is_reset: | ||||
|         # Invalidate the one-time reset session only after credential persisted | ||||
|         await db.instance.delete_session(s.key) | ||||
|         auth = await create_session( | ||||
|             user_uuid, credential.uuid, infodict(ws, "authenticated") | ||||
|     # Create a new session and store everything in database | ||||
|     token = create_token() | ||||
|     await db.instance.create_credential_session(  # type: ignore[attr-defined] | ||||
|         user_uuid=user_uuid, | ||||
|         credential=credential, | ||||
|         reset_key=(s.key if reset is not None else None), | ||||
|         session_key=session_key(token), | ||||
|         session_expires=expires(), | ||||
|         session_info=infodict(ws, "authenticated"), | ||||
|         display_name=user_name, | ||||
|     ) | ||||
|     auth = token | ||||
|  | ||||
|     assert isinstance(auth, str) and len(auth) == 16 | ||||
|     await ws.send_json( | ||||
|   | ||||
| @@ -7,4 +7,5 @@ def assert_safe(value: str, *, field: str = "value") -> None: | ||||
|     if not isinstance(value, str) or not value or not _SAFE_RE.match(value): | ||||
|         raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$") | ||||
|  | ||||
|  | ||||
| __all__ = ["assert_safe"] | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko