Add preferred username and email to user data.
This commit is contained in:
@@ -16,6 +16,8 @@
|
||||
v-if="authStore.userInfo?.ctx"
|
||||
ref="userBasicInfo"
|
||||
:name="authStore.userInfo.ctx.user.display_name"
|
||||
:email="authStore.userInfo.ctx.user.email"
|
||||
:preferred_username="authStore.userInfo.ctx.user.preferred_username"
|
||||
:visits="authStore.userInfo.visits"
|
||||
:created-at="authStore.userInfo.created_at"
|
||||
:last-seen="authStore.userInfo.last_seen"
|
||||
@@ -23,6 +25,8 @@
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
@edit-email="openEmailDialog"
|
||||
@edit-preferred-username="openPreferredUsernameDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
>
|
||||
<div class="remote-auth-inline">
|
||||
@@ -91,6 +95,35 @@
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="showEmailDialog" @close="showEmailDialog = false">
|
||||
<h3>Edit Email</h3>
|
||||
<form @submit.prevent="saveEmail" class="modal-form">
|
||||
<NameEditForm
|
||||
label="Email"
|
||||
placeholder="user@example.com"
|
||||
input-type="email"
|
||||
v-model="newEmail"
|
||||
:busy="saving"
|
||||
:error="emailError"
|
||||
@cancel="showEmailDialog = false"
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="showPreferredUsernameDialog" @close="showPreferredUsernameDialog = false">
|
||||
<h3>Edit Preferred Username</h3>
|
||||
<form @submit.prevent="savePreferredUsername" class="modal-form">
|
||||
<NameEditForm
|
||||
label="Preferred Username"
|
||||
placeholder="username"
|
||||
v-model="newPreferredUsername"
|
||||
:busy="saving"
|
||||
:error="preferredUsernameError"
|
||||
@cancel="showPreferredUsernameDialog = false"
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||
<div class="button-row" ref="logoutButtons">
|
||||
<button
|
||||
@@ -143,9 +176,15 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showNameDialog = ref(false)
|
||||
const showEmailDialog = ref(false)
|
||||
const showPreferredUsernameDialog = ref(false)
|
||||
const showRegLink = ref(false)
|
||||
const newName = ref('')
|
||||
const newEmail = ref('')
|
||||
const newPreferredUsername = ref('')
|
||||
const saving = ref(false)
|
||||
const emailError = ref('')
|
||||
const preferredUsernameError = ref('')
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const showDeviceInfo = ref(false)
|
||||
@@ -159,9 +198,21 @@ const userBasicInfo = ref(null)
|
||||
const userInfoSection = ref(null)
|
||||
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
||||
const hasActiveModal = computed(() => showNameDialog.value || showEmailDialog.value || showPreferredUsernameDialog.value || showRegLink.value)
|
||||
|
||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
|
||||
watch(showEmailDialog, (newVal) => {
|
||||
if (newVal) {
|
||||
newEmail.value = authStore.userInfo?.ctx.user.email ?? ''
|
||||
emailError.value = ''
|
||||
}
|
||||
})
|
||||
watch(showPreferredUsernameDialog, (newVal) => {
|
||||
if (newVal) {
|
||||
newPreferredUsername.value = authStore.userInfo?.ctx.user.preferred_username ?? ''
|
||||
preferredUsernameError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||
@@ -370,6 +421,46 @@ const saveName = async () => {
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
const openEmailDialog = () => {
|
||||
showEmailDialog.value = true
|
||||
}
|
||||
|
||||
const saveEmail = async () => {
|
||||
try {
|
||||
emailError.value = ''
|
||||
saving.value = true
|
||||
const email = newEmail.value.trim() || null
|
||||
await apiJson('/auth/api/user/email', { method: 'PATCH', body: { email } })
|
||||
showEmailDialog.value = false
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Email updated successfully!', 'success', 3000)
|
||||
} catch (e) {
|
||||
emailError.value = e.message || 'Failed to update email'
|
||||
authStore.showMessage(emailError.value, 'error')
|
||||
}
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
const openPreferredUsernameDialog = () => {
|
||||
showPreferredUsernameDialog.value = true
|
||||
}
|
||||
|
||||
const savePreferredUsername = async () => {
|
||||
try {
|
||||
preferredUsernameError.value = ''
|
||||
saving.value = true
|
||||
const preferred_username = newPreferredUsername.value.trim() || null
|
||||
await apiJson('/auth/api/user/preferred-username', { method: 'PATCH', body: { preferred_username } })
|
||||
showPreferredUsernameDialog.value = false
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Preferred username updated successfully!', 'success', 3000)
|
||||
} catch (e) {
|
||||
preferredUsernameError.value = e.message || 'Failed to update preferred username'
|
||||
authStore.showMessage(preferredUsernameError.value, 'error')
|
||||
}
|
||||
finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
<span class="date-label"><strong>Last seen:</strong></span>
|
||||
<span class="date-value">{{ formatDate(lastSeen) }}</span>
|
||||
</div>
|
||||
<div class="oidc-info">
|
||||
<div class="oidc-field">
|
||||
<span class="field-label"><strong>Email:</strong></span>
|
||||
<span class="field-value">{{ email || '—' }}</span>
|
||||
<button v-if="canEdit" class="mini-btn" @click="emit('editEmail')" title="Edit email">✏️</button>
|
||||
</div>
|
||||
<div class="oidc-field">
|
||||
<span class="field-label"><strong>Preferred username:</strong></span>
|
||||
<span class="field-value">{{ preferred_username || '—' }}</span>
|
||||
<button v-if="canEdit" class="mini-btn" @click="emit('editPreferredUsername')" title="Edit preferred username">✏️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="user-info-extra">
|
||||
<slot></slot>
|
||||
</div>
|
||||
@@ -32,6 +44,8 @@ import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
email: { type: String, default: null },
|
||||
preferred_username: { type: String, default: null },
|
||||
visits: { type: [Number, String], default: 0 },
|
||||
createdAt: { type: [String, Number, Date], default: null },
|
||||
lastSeen: { type: [String, Number, Date], default: null },
|
||||
@@ -42,7 +56,7 @@ const props = defineProps({
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'editName'])
|
||||
const emit = defineEmits(['saved', 'editName', 'editEmail', 'editPreferredUsername'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
@@ -56,7 +70,8 @@ const userLoaded = computed(() => !!props.name)
|
||||
"org org extra"
|
||||
"label1 value1 extra"
|
||||
"label2 value2 extra"
|
||||
"label3 value3 extra";
|
||||
"label3 value3 extra"
|
||||
"oidc oidc extra";
|
||||
}
|
||||
|
||||
.user-info:not(.has-extra) {
|
||||
@@ -66,7 +81,8 @@ const userLoaded = computed(() => !!props.name)
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3";
|
||||
"label3 value3"
|
||||
"oidc oidc";
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -92,6 +108,11 @@ const userLoaded = computed(() => !!props.name)
|
||||
.info-value:nth-of-type(4) { grid-area: value2; }
|
||||
.info-label:nth-of-type(5) { grid-area: label3; }
|
||||
.info-value:nth-of-type(6) { grid-area: value3; }
|
||||
.oidc-info { grid-area: oidc; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--color-border); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.oidc-field { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9em; }
|
||||
.oidc-field .field-label { flex-shrink: 0; }
|
||||
.oidc-field .field-value { flex: 1; word-break: break-all; color: var(--color-text-muted); }
|
||||
.oidc-field .mini-btn { flex-shrink: 0; }
|
||||
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
|
||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
||||
.user-name-row.editing { flex: 1 1 auto; }
|
||||
|
||||
@@ -62,6 +62,8 @@ from paskia.db.operations import (
|
||||
update_role_name,
|
||||
update_session,
|
||||
update_user_display_name,
|
||||
update_user_email,
|
||||
update_user_preferred_username,
|
||||
update_user_role,
|
||||
update_user_theme,
|
||||
)
|
||||
@@ -146,6 +148,8 @@ __all__ = [
|
||||
"update_role_name",
|
||||
"update_session",
|
||||
"update_user_display_name",
|
||||
"update_user_email",
|
||||
"update_user_preferred_username",
|
||||
"update_user_role",
|
||||
"update_user_theme",
|
||||
# OIDC
|
||||
|
||||
@@ -287,6 +287,48 @@ def update_user_theme(
|
||||
_db.users[uuid].theme = theme
|
||||
|
||||
|
||||
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()
|
||||
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:
|
||||
preferred_username = (preferred_username or "").strip()
|
||||
if not preferred_username:
|
||||
preferred_username = None
|
||||
elif 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
|
||||
|
||||
|
||||
def update_user_role(
|
||||
uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
|
||||
@@ -80,6 +80,46 @@ async def user_update_theme(
|
||||
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.post("/logout-all")
|
||||
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
|
||||
@@ -17,6 +17,10 @@ def build_session_context(ctx: SessionContext) -> dict:
|
||||
}
|
||||
if ctx.user.theme:
|
||||
result["user"]["theme"] = ctx.user.theme
|
||||
if ctx.user.email:
|
||||
result["user"]["email"] = ctx.user.email
|
||||
if ctx.user.preferred_username:
|
||||
result["user"]["preferred_username"] = ctx.user.preferred_username
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user