Simplified user info update APIs, DB and frontend to remove separate update functions for each property. Automatically choose preferred username for users as they register, based on display name.

This commit is contained in:
Leo Vasanko
2026-02-17 18:16:32 +00:00
parent 8128d40202
commit 7a09dbc040
10 changed files with 197 additions and 222 deletions
+1 -1
View File
@@ -808,7 +808,7 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation // Close dialog immediately, then perform async operation
closeDialog() 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(() => { .then(() => {
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500) authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
onUserNameSaved() onUserNameSaved()
+1 -1
View File
@@ -189,7 +189,7 @@ defineExpose({ focusFirstElement })
:loading="loading" :loading="loading"
:org-display-name="userDetail.org.display_name" :org-display-name="userDetail.org.display_name"
:role-name="userDetail.role" :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')" @saved="$emit('onUserNameSaved')"
@edit="handleEditName" @edit="handleEditName"
> >
+1 -1
View File
@@ -817,7 +817,7 @@ th {
display: grid; display: grid;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-surface); background: var(--color-surface);
padding: 1.1rem 1.25rem; padding: 1rem;
} }
.user-details { .user-details {
+8 -12
View File
@@ -25,7 +25,7 @@
:loading="authStore.isLoading" :loading="authStore.isLoading"
:org-display-name="authStore.userInfo.ctx.org.display_name" :org-display-name="authStore.userInfo.ctx.org.display_name"
:role-name="authStore.userInfo.ctx.role.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()" @saved="authStore.loadUserInfo()"
@edit="openEditDialog" @edit="openEditDialog"
@keydown="handleUserInfoKeydown" @keydown="handleUserInfoKeydown"
@@ -397,17 +397,13 @@ const saveProfile = async () => {
try { try {
editError.value = '' editError.value = ''
saving.value = true saving.value = true
const tasks = [] const body = {}
if (name !== user.display_name) if (name !== user.display_name) body.display_name = name
tasks.push(apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })) if (emailVal !== (user.email || null)) body.email = emailVal
if (emailVal !== (user.email || null)) if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
tasks.push(apiJson('/auth/api/user/email', { method: 'PATCH', body: { email: emailVal } })) if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
if (usernameVal !== (user.preferred_username || null)) if (Object.keys(body).length) {
tasks.push(apiJson('/auth/api/user/preferred-username', { method: 'PATCH', body: { preferred_username: usernameVal } })) await apiJson('/auth/api/user/info', { method: 'PATCH', body })
if (telephoneVal !== (user.telephone || null))
tasks.push(apiJson('/auth/api/user/telephone', { method: 'PATCH', body: { telephone: telephoneVal } }))
if (tasks.length) {
await Promise.all(tasks)
await authStore.loadUserInfo() await authStore.loadUserInfo()
authStore.showMessage('Profile updated!', 'success', 3000) authStore.showMessage('Profile updated!', 'success', 3000)
} }
+1 -1
View File
@@ -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; } .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-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 { 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-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
.info-date { color: var(--color-text) !important; } .info-date { color: var(--color-text) !important; }
.info-label { color: var(--color-text) !important; } .info-label { color: var(--color-text) !important; }
+4 -8
View File
@@ -62,11 +62,9 @@ from paskia.db.operations import (
update_role_name, update_role_name,
update_session, update_session,
update_user_display_name, update_user_display_name,
update_user_email, update_user_info,
update_user_preferred_username,
update_user_role, update_user_role,
update_user_telephone, is_username_taken,
update_user_theme,
) )
from paskia.db.structs import ( from paskia.db.structs import (
DB, DB,
@@ -149,11 +147,9 @@ __all__ = [
"update_role_name", "update_role_name",
"update_session", "update_session",
"update_user_display_name", "update_user_display_name",
"update_user_email", "update_user_info",
"update_user_preferred_username",
"update_user_role", "update_user_role",
"update_user_telephone", "is_username_taken",
"update_user_theme",
# OIDC # OIDC
"create_oid_client", "create_oid_client",
"update_oid_client", "update_oid_client",
+75 -53
View File
@@ -31,6 +31,7 @@ from paskia.db.structs import (
User, User,
) )
from paskia.util.crypto import hash_secret from paskia.util.crypto import hash_secret
from paskia.util.nameutil import slugify_name
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -44,6 +45,17 @@ _db._store = _store
_initialized = False _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) # 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. The acting user should be logged via ctx.
For self-service (user updating own name), pass user's ctx. For self-service (user updating own name), pass user's ctx.
For admin operations, pass admin'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): if isinstance(uuid, str):
uuid = UUID(uuid) uuid = UUID(uuid)
if uuid not in _db.users: if uuid not in _db.users:
raise ValueError(f"User {uuid} not found") 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): 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, 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, ctx: SessionContext | None = 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): if isinstance(uuid, str):
uuid = UUID(uuid) uuid = UUID(uuid)
if uuid not in _db.users: if uuid not in _db.users:
raise ValueError(f"User {uuid} not found") raise ValueError(f"User {uuid} not found")
user = _db.users[uuid]
# 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"): if theme not in ("", "light", "dark"):
raise ValueError(f"Invalid theme: {theme}") raise ValueError(f"Invalid theme: {theme}")
with _db.transaction("update_user_theme", ctx):
_db.users[uuid].theme = theme
if email is not _UNSET and email is not None:
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:
email = (email or "").strip() email = (email or "").strip()
if not email: if not email:
email = None email = None
elif "@" not in email or len(email) > 254: elif "@" not in email or len(email) > 254:
raise ValueError("Invalid email format") raise ValueError("Invalid email format")
with _db.transaction("update_user_email", ctx):
_db.users[uuid].email = email
if preferred_username is not _UNSET and preferred_username is not None:
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:
preferred_username = (preferred_username or "").strip() preferred_username = (preferred_username or "").strip()
if not preferred_username: if not preferred_username:
preferred_username = None raise ValueError("Preferred username cannot be empty (use None to clear)")
elif len(preferred_username) > 128: if len(preferred_username) > 128:
raise ValueError("preferred_username too long") raise ValueError("preferred_username too long")
with _db.transaction("update_user_preferred_username", ctx): if is_username_taken(preferred_username, exclude_uuid=uuid):
_db.users[uuid].preferred_username = preferred_username raise ValueError("Username already taken")
if telephone is not _UNSET and telephone is not None:
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:
telephone = (telephone or "").strip() telephone = (telephone or "").strip()
if not telephone: if not telephone:
telephone = None telephone = None
elif len(telephone) > 32: elif len(telephone) > 32:
raise ValueError("telephone too long") 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( def update_user_role(
+23 -83
View File
@@ -607,13 +607,17 @@ async def admin_get_user_detail(
) )
@app.patch("/users/{user_uuid}/display-name") @app.patch("/users/{user_uuid}/info")
async def admin_update_user_display_name( async def admin_update_user_info(
user_uuid: UUID, user_uuid: UUID,
request: Request, request: Request,
payload: dict = Body(...), payload: dict = Body(...),
auth=AUTH_COOKIE, 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: try:
user = db.data().users[user_uuid] user = db.data().users[user_uuid]
except KeyError: except KeyError:
@@ -628,90 +632,26 @@ async def admin_update_user_display_name(
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
new_name = (payload.get("display_name") or "").strip()
if not new_name: kwargs = {}
raise HTTPException(status_code=400, detail="display_name required") if "display_name" in payload:
if len(new_name) > 64: 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") raise HTTPException(status_code=400, detail="display_name too long")
db.update_user_display_name(user_uuid, new_name, ctx=ctx) kwargs["display_name"] = name
return {"status": "ok"} 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")
@app.patch("/users/{user_uuid}/email") db.update_user_info(user_uuid, **kwargs, ctx=ctx)
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"}
@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)
return {"status": "ok"} return {"status": "ok"}
+45 -61
View File
@@ -39,6 +39,7 @@ async def user_update_display_name(
payload: dict = Body(...), payload: dict = Body(...),
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
"""Update display name only. Used by registration flow (auto-fills preferred_username)."""
if not auth: if not auth:
raise authz.AuthException( raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login" status_code=401, detail="Authentication Required", mode="login"
@@ -58,6 +59,49 @@ async def user_update_display_name(
return {"status": "ok"} 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") @app.patch("/theme")
async def user_update_theme( async def user_update_theme(
request: Request, request: Request,
@@ -76,67 +120,7 @@ async def user_update_theme(
theme = payload.get("theme", "") theme = payload.get("theme", "")
if theme not in ("", "light", "dark"): if theme not in ("", "light", "dark"):
raise HTTPException(status_code=400, detail="Invalid theme") raise HTTPException(status_code=400, detail="Invalid theme")
db.update_user_theme(ctx.user.uuid, theme, ctx=ctx) db.update_user_info(ctx.user.uuid, theme=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)
return {"status": "ok"} return {"status": "ok"}
+37
View File
@@ -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(".")