diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index bb52f5d..97abff4 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -808,7 +808,7 @@ async function submitDialog() { // Close dialog immediately, then perform async operation closeDialog() - apiJson(`/auth/api/admin/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } }) + apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } }) .then(() => { authStore.showMessage(`User renamed to "${name}".`, 'success', 2500) onUserNameSaved() diff --git a/frontend/src/admin/AdminUserDetail.vue b/frontend/src/admin/AdminUserDetail.vue index 9cd528a..825ad61 100644 --- a/frontend/src/admin/AdminUserDetail.vue +++ b/frontend/src/admin/AdminUserDetail.vue @@ -189,7 +189,7 @@ defineExpose({ focusFirstElement }) :loading="loading" :org-display-name="userDetail.org.display_name" :role-name="userDetail.role" - :update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`" + :update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`" @saved="$emit('onUserNameSaved')" @edit="handleEditName" > diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 772b4d2..7e7bc37 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -817,7 +817,7 @@ th { display: grid; border-radius: var(--radius-md); background: var(--color-surface); - padding: 1.1rem 1.25rem; + padding: 1rem; } .user-details { diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue index c2faf3d..cf464b7 100644 --- a/frontend/src/components/ProfileView.vue +++ b/frontend/src/components/ProfileView.vue @@ -25,7 +25,7 @@ :loading="authStore.isLoading" :org-display-name="authStore.userInfo.ctx.org.display_name" :role-name="authStore.userInfo.ctx.role.display_name" - update-endpoint="/auth/api/user/display-name" + update-endpoint="/auth/api/user/info" @saved="authStore.loadUserInfo()" @edit="openEditDialog" @keydown="handleUserInfoKeydown" @@ -397,17 +397,13 @@ const saveProfile = async () => { try { editError.value = '' saving.value = true - const tasks = [] - if (name !== user.display_name) - tasks.push(apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })) - if (emailVal !== (user.email || null)) - tasks.push(apiJson('/auth/api/user/email', { method: 'PATCH', body: { email: emailVal } })) - if (usernameVal !== (user.preferred_username || null)) - tasks.push(apiJson('/auth/api/user/preferred-username', { method: 'PATCH', body: { preferred_username: usernameVal } })) - if (telephoneVal !== (user.telephone || null)) - tasks.push(apiJson('/auth/api/user/telephone', { method: 'PATCH', body: { telephone: telephoneVal } })) - if (tasks.length) { - await Promise.all(tasks) + const body = {} + if (name !== user.display_name) body.display_name = name + if (emailVal !== (user.email || null)) body.email = emailVal + if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal + if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal + if (Object.keys(body).length) { + await apiJson('/auth/api/user/info', { method: 'PATCH', body }) await authStore.loadUserInfo() authStore.showMessage('Profile updated!', 'success', 3000) } diff --git a/frontend/src/components/UserBasicInfo.vue b/frontend/src/components/UserBasicInfo.vue index 4f27af0..b61e913 100644 --- a/frontend/src/components/UserBasicInfo.vue +++ b/frontend/src/components/UserBasicInfo.vue @@ -109,7 +109,7 @@ const userLoaded = computed(() => !!props.name) .info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; } .contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.contact-link:hover { transform: scale(1.05); } +.contact-link:hover { transform: scale(1.01); } .info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; } .info-date { color: var(--color-text) !important; } .info-label { color: var(--color-text) !important; } diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index 266a9e9..17794f8 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -62,11 +62,9 @@ from paskia.db.operations import ( update_role_name, update_session, update_user_display_name, - update_user_email, - update_user_preferred_username, + update_user_info, update_user_role, - update_user_telephone, - update_user_theme, + is_username_taken, ) from paskia.db.structs import ( DB, @@ -149,11 +147,9 @@ __all__ = [ "update_role_name", "update_session", "update_user_display_name", - "update_user_email", - "update_user_preferred_username", + "update_user_info", "update_user_role", - "update_user_telephone", - "update_user_theme", + "is_username_taken", # OIDC "create_oid_client", "update_oid_client", diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 321e6b8..fb6f60f 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -31,6 +31,7 @@ from paskia.db.structs import ( User, ) from paskia.util.crypto import hash_secret +from paskia.util.nameutil import slugify_name _logger = logging.getLogger(__name__) @@ -44,6 +45,17 @@ _db._store = _store _initialized = False +def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool: + """Check if a preferred_username is already taken by another user.""" + if not username: + return False + for user in _db.users.values(): + if user.preferred_username == username: + if exclude_uuid is None or user.uuid != exclude_uuid: + return True + return False + + # ------------------------------------------------------------------------- # Write operations (validate, modify, commit or raise ValueError) # ------------------------------------------------------------------------- @@ -261,93 +273,103 @@ def update_user_display_name( The acting user should be logged via ctx. For self-service (user updating own name), pass user's ctx. For admin operations, pass admin's ctx. + + If the user's preferred_username is currently None, this will auto-fill it + with a slugified version of the display name (if unique and non-empty). """ if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db.users: raise ValueError(f"User {uuid} not found") + display_name = (display_name or "").strip() + if not display_name: + raise ValueError("Display name cannot be empty") + user = _db.users[uuid] with _db.transaction("update_user_display_name", ctx): - _db.users[uuid].display_name = display_name + user.display_name = display_name + # Auto-fill preferred_username if not already set + if user.preferred_username is None: + slug = slugify_name(display_name) + if slug and not is_username_taken(slug, exclude_uuid=uuid): + user.preferred_username = slug -def update_user_theme( +def update_user_info( uuid: UUID, - theme: str, *, + display_name: str | object = _UNSET, + theme: str | object = _UNSET, + email: str | None | object = _UNSET, + preferred_username: str | None | object = _UNSET, + telephone: str | None | object = _UNSET, ctx: SessionContext | None = None, ) -> None: - """Update user theme preference ('' for auto, 'light', 'dark').""" + """Update user profile information. + + Pass only the fields you want to update. Use None to clear optional fields. + This does NOT auto-fill preferred_username - use update_user_display_name + for the registration flow where auto-fill is desired. + + Args: + uuid: User UUID + display_name: User display name (cannot be empty) + theme: Theme preference ('' for auto, 'light', 'dark') + email: Email address (None to clear) + preferred_username: Username for OIDC claims (None to clear, must be unique) + telephone: Phone number (None to clear) + ctx: Session context for audit logging + """ if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db.users: raise ValueError(f"User {uuid} not found") - if theme not in ("", "light", "dark"): - raise ValueError(f"Invalid theme: {theme}") - with _db.transaction("update_user_theme", ctx): - _db.users[uuid].theme = theme + user = _db.users[uuid] -def update_user_email( - uuid: UUID, - email: str | None, - *, - ctx: SessionContext | None = None, -) -> None: - """Update user email for OIDC email claim. Can be None to clear.""" - if isinstance(uuid, str): - uuid = UUID(uuid) - if uuid not in _db.users: - raise ValueError(f"User {uuid} not found") - if email is not None: + # Validate all fields before transaction + if display_name is not _UNSET: + display_name = (display_name or "").strip() + if not display_name: + raise ValueError("Display name cannot be empty") + + if theme is not _UNSET: + if theme not in ("", "light", "dark"): + raise ValueError(f"Invalid theme: {theme}") + + if email is not _UNSET and email is not None: email = (email or "").strip() if not email: email = None elif "@" not in email or len(email) > 254: raise ValueError("Invalid email format") - with _db.transaction("update_user_email", ctx): - _db.users[uuid].email = email - -def update_user_preferred_username( - uuid: UUID, - preferred_username: str | None, - *, - ctx: SessionContext | None = None, -) -> None: - """Update user preferred_username for OIDC preferred_username claim. Can be None to clear.""" - if isinstance(uuid, str): - uuid = UUID(uuid) - if uuid not in _db.users: - raise ValueError(f"User {uuid} not found") - if preferred_username is not None: + if preferred_username is not _UNSET and preferred_username is not None: preferred_username = (preferred_username or "").strip() if not preferred_username: - preferred_username = None - elif len(preferred_username) > 128: + raise ValueError("Preferred username cannot be empty (use None to clear)") + if len(preferred_username) > 128: raise ValueError("preferred_username too long") - with _db.transaction("update_user_preferred_username", ctx): - _db.users[uuid].preferred_username = preferred_username + if is_username_taken(preferred_username, exclude_uuid=uuid): + raise ValueError("Username already taken") - -def update_user_telephone( - uuid: UUID, - telephone: str | None, - *, - ctx: SessionContext | None = None, -) -> None: - """Update user telephone number. Can be None to clear.""" - if isinstance(uuid, str): - uuid = UUID(uuid) - if uuid not in _db.users: - raise ValueError(f"User {uuid} not found") - if telephone is not None: + if telephone is not _UNSET and telephone is not None: telephone = (telephone or "").strip() if not telephone: telephone = None elif len(telephone) > 32: raise ValueError("telephone too long") - with _db.transaction("update_user_telephone", ctx): - _db.users[uuid].telephone = telephone + + with _db.transaction("update_user_info", ctx): + if display_name is not _UNSET: + user.display_name = display_name + if theme is not _UNSET: + user.theme = theme + if email is not _UNSET: + user.email = email + if preferred_username is not _UNSET: + user.preferred_username = preferred_username + if telephone is not _UNSET: + user.telephone = telephone def update_user_role( diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index cf79633..f0fc7dc 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -607,13 +607,17 @@ async def admin_get_user_detail( ) -@app.patch("/users/{user_uuid}/display-name") -async def admin_update_user_display_name( +@app.patch("/users/{user_uuid}/info") +async def admin_update_user_info( user_uuid: UUID, request: Request, payload: dict = Body(...), auth=AUTH_COOKIE, ): + """Update user profile info (display_name, email, preferred_username, telephone). + + Pass only the fields you want to update. Use null to clear optional fields. + """ try: user = db.data().users[user_uuid] except KeyError: @@ -628,90 +632,26 @@ async def admin_update_user_display_name( raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - 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") - db.update_user_display_name(user_uuid, new_name, ctx=ctx) - return {"status": "ok"} + kwargs = {} + if "display_name" in payload: + name = (payload["display_name"] or "").strip() + if not name: + raise HTTPException(status_code=400, detail="display_name cannot be empty") + if len(name) > 64: + raise HTTPException(status_code=400, detail="display_name too long") + kwargs["display_name"] = name + if "email" in payload: + kwargs["email"] = payload["email"] + if "preferred_username" in payload: + kwargs["preferred_username"] = payload["preferred_username"] + if "telephone" in payload: + kwargs["telephone"] = payload["telephone"] -@app.patch("/users/{user_uuid}/email") -async def admin_update_user_email( - user_uuid: UUID, - request: Request, - payload: dict = Body(...), - auth=AUTH_COOKIE, -): - try: - user = db.data().users[user_uuid] - except KeyError: - raise HTTPException(status_code=404, detail="User not found") - ctx = await authz.verify( - auth, - ["auth:admin", "auth:org:admin"], - match=permutil.has_any, - host=request.headers.get("host"), - ) - if not can_manage_org(ctx, user.org.uuid): - raise authz.AuthException( - status_code=403, detail="Insufficient permissions", mode="forbidden" - ) - email = payload.get("email") - db.update_user_email(user_uuid, email, ctx=ctx) - return {"status": "ok"} + if not kwargs: + raise HTTPException(status_code=400, detail="No fields to update") - -@app.patch("/users/{user_uuid}/preferred-username") -async def admin_update_user_preferred_username( - user_uuid: UUID, - request: Request, - payload: dict = Body(...), - auth=AUTH_COOKIE, -): - try: - user = db.data().users[user_uuid] - except KeyError: - raise HTTPException(status_code=404, detail="User not found") - ctx = await authz.verify( - auth, - ["auth:admin", "auth:org:admin"], - match=permutil.has_any, - host=request.headers.get("host"), - ) - if not can_manage_org(ctx, user.org.uuid): - raise authz.AuthException( - status_code=403, detail="Insufficient permissions", mode="forbidden" - ) - preferred_username = payload.get("preferred_username") - db.update_user_preferred_username(user_uuid, preferred_username, ctx=ctx) - return {"status": "ok"} - - -@app.patch("/users/{user_uuid}/telephone") -async def admin_update_user_telephone( - user_uuid: UUID, - request: Request, - payload: dict = Body(...), - auth=AUTH_COOKIE, -): - try: - user = db.data().users[user_uuid] - except KeyError: - raise HTTPException(status_code=404, detail="User not found") - ctx = await authz.verify( - auth, - ["auth:admin", "auth:org:admin"], - match=permutil.has_any, - host=request.headers.get("host"), - ) - if not can_manage_org(ctx, user.org.uuid): - raise authz.AuthException( - status_code=403, detail="Insufficient permissions", mode="forbidden" - ) - telephone = payload.get("telephone") - db.update_user_telephone(user_uuid, telephone, ctx=ctx) + db.update_user_info(user_uuid, **kwargs, ctx=ctx) return {"status": "ok"} diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 2419c29..f760561 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -39,6 +39,7 @@ async def user_update_display_name( payload: dict = Body(...), auth=AUTH_COOKIE, ): + """Update display name only. Used by registration flow (auto-fills preferred_username).""" if not auth: raise authz.AuthException( status_code=401, detail="Authentication Required", mode="login" @@ -58,6 +59,49 @@ async def user_update_display_name( return {"status": "ok"} +@app.patch("/info") +async def user_update_info( + request: Request, + payload: dict = Body(...), + auth=AUTH_COOKIE, +): + """Update user profile info (display_name, email, preferred_username, telephone). + + Pass only the fields you want to update. Use null to clear optional fields. + Does NOT auto-fill preferred_username (unlike /display-name endpoint). + """ + if not auth: + raise authz.AuthException( + status_code=401, detail="Authentication Required", mode="login" + ) + ctx = db.data().session_ctx(auth, request.headers.get("host")) + if not ctx: + raise authz.AuthException( + status_code=401, detail="Session expired", mode="login" + ) + + kwargs = {} + if "display_name" in payload: + name = (payload["display_name"] or "").strip() + if not name: + raise HTTPException(status_code=400, detail="display_name cannot be empty") + if len(name) > 64: + raise HTTPException(status_code=400, detail="display_name too long") + kwargs["display_name"] = name + if "email" in payload: + kwargs["email"] = payload["email"] + if "preferred_username" in payload: + kwargs["preferred_username"] = payload["preferred_username"] + if "telephone" in payload: + kwargs["telephone"] = payload["telephone"] + + if not kwargs: + raise HTTPException(status_code=400, detail="No fields to update") + + db.update_user_info(ctx.user.uuid, **kwargs, ctx=ctx) + return {"status": "ok"} + + @app.patch("/theme") async def user_update_theme( request: Request, @@ -76,67 +120,7 @@ async def user_update_theme( theme = payload.get("theme", "") if theme not in ("", "light", "dark"): raise HTTPException(status_code=400, detail="Invalid theme") - db.update_user_theme(ctx.user.uuid, theme, ctx=ctx) - return {"status": "ok"} - - -@app.patch("/email") -async def user_update_email( - request: Request, - payload: dict = Body(...), - auth=AUTH_COOKIE, -): - if not auth: - raise authz.AuthException( - status_code=401, detail="Authentication Required", mode="login" - ) - ctx = db.data().session_ctx(auth, request.headers.get("host")) - if not ctx: - raise authz.AuthException( - status_code=401, detail="Session expired", mode="login" - ) - email = payload.get("email") - db.update_user_email(ctx.user.uuid, email, ctx=ctx) - return {"status": "ok"} - - -@app.patch("/preferred-username") -async def user_update_preferred_username( - request: Request, - payload: dict = Body(...), - auth=AUTH_COOKIE, -): - if not auth: - raise authz.AuthException( - status_code=401, detail="Authentication Required", mode="login" - ) - ctx = db.data().session_ctx(auth, request.headers.get("host")) - if not ctx: - raise authz.AuthException( - status_code=401, detail="Session expired", mode="login" - ) - preferred_username = payload.get("preferred_username") - db.update_user_preferred_username(ctx.user.uuid, preferred_username, ctx=ctx) - return {"status": "ok"} - - -@app.patch("/telephone") -async def user_update_telephone( - request: Request, - payload: dict = Body(...), - auth=AUTH_COOKIE, -): - if not auth: - raise authz.AuthException( - status_code=401, detail="Authentication Required", mode="login" - ) - ctx = db.data().session_ctx(auth, request.headers.get("host")) - if not ctx: - raise authz.AuthException( - status_code=401, detail="Session expired", mode="login" - ) - telephone = payload.get("telephone") - db.update_user_telephone(ctx.user.uuid, telephone, ctx=ctx) + db.update_user_info(ctx.user.uuid, theme=theme, ctx=ctx) return {"status": "ok"} diff --git a/paskia/util/nameutil.py b/paskia/util/nameutil.py new file mode 100644 index 0000000..e165ab2 --- /dev/null +++ b/paskia/util/nameutil.py @@ -0,0 +1,37 @@ +"""Name and username utilities.""" + +import re +import unicodedata + + +def slugify_name(name: str) -> str: + """Convert display name to slug-compatible username. + + Uses dots as separators, preserves existing dots and dashes. + Strips trailing parenthesized content and other unwanted punctuation. + + Examples: + 'John Doe' → 'john.doe' + 'María José García' → 'maria.jose.garcia' + 'Jean-Pierre' → 'jean-pierre' + 'John.Doe' → 'john.doe' + 'John Doe (Admin)' → 'john.doe' + ' Multiple Spaces ' → 'multiple.spaces' + + Returns empty string for empty/whitespace-only input. + """ + if not name: + return "" + # Strip trailing parenthesized content (e.g., " (Admin)") + name = re.sub(r"\s*\([^)]*\)\s*$", "", name) + # Normalize unicode → ASCII equivalent (é → e, ñ → n) + name = unicodedata.normalize("NFKD", name) + name = name.encode("ascii", "ignore").decode("ascii") + # Lowercase + name = name.lower() + # Replace non-alphanumeric (except . and -) with dots + name = re.sub(r"[^a-z0-9.-]+", ".", name) + # Collapse multiple dots + name = re.sub(r"\.+", ".", name) + # Strip leading/trailing dots + return name.strip(".")