OAuth2 OpenID Connect provider support etc. #3
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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",
|
||||
|
||||
+76
-54
@@ -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(
|
||||
|
||||
+23
-83
@@ -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"}
|
||||
|
||||
|
||||
|
||||
+45
-61
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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(".")
|
||||
Reference in New Issue
Block a user