New user basic info card and improved layout, telephone number and other contact details shown.
This commit is contained in:
@@ -184,12 +184,14 @@ defineExpose({ focusFirstElement })
|
||||
:visits="userDetail.visits"
|
||||
:created-at="userDetail.created_at"
|
||||
:last-seen="userDetail.last_seen"
|
||||
:email="userDetail.email"
|
||||
:telephone="userDetail.telephone"
|
||||
:loading="loading"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@edit-name="handleEditName"
|
||||
@edit="handleEditName"
|
||||
>
|
||||
<div class="admin-actions">
|
||||
<button
|
||||
|
||||
@@ -325,6 +325,7 @@ input:not([type]),
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="email"],
|
||||
input[type="tel"],
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
@@ -341,6 +342,7 @@ input:not([type]):focus,
|
||||
input[type="text"]:focus,
|
||||
input[type="search"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="tel"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
:visits="authStore.userInfo?.visits || 0"
|
||||
:created-at="authStore.userInfo?.created_at"
|
||||
:last-seen="authStore.userInfo?.last_seen"
|
||||
:email="ctx.user.email"
|
||||
:telephone="ctx.user.telephone"
|
||||
:org-display-name="orgDisplayName"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
|
||||
@@ -18,15 +18,16 @@
|
||||
:name="authStore.userInfo.ctx.user.display_name"
|
||||
:email="authStore.userInfo.ctx.user.email"
|
||||
:preferred_username="authStore.userInfo.ctx.user.preferred_username"
|
||||
:telephone="authStore.userInfo.ctx.user.telephone"
|
||||
:visits="authStore.userInfo.visits"
|
||||
:created-at="authStore.userInfo.created_at"
|
||||
:last-seen="authStore.userInfo.last_seen"
|
||||
: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"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit-name="openNameDialog"
|
||||
@edit-email="openEmailDialog"
|
||||
@edit-preferred-username="openPreferredUsernameDialog"
|
||||
@edit="openEditDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
>
|
||||
<div class="remote-auth-inline">
|
||||
@@ -40,7 +41,7 @@
|
||||
@device-info-visible="showDeviceInfo = $event"
|
||||
/>
|
||||
</div>
|
||||
<p class="remote-auth-description">Provided by another device requesting remote auth.</p>
|
||||
<p class="remote-auth-description">Login from another device</p>
|
||||
</UserBasicInfo>
|
||||
</section>
|
||||
|
||||
@@ -83,44 +84,28 @@
|
||||
section-description="You are currently signed in to the following sessions. If you don't recognize something, consider deleting not only the session but the associated passkey you suspect is compromised, as only this terminates all linked sessions and prevents logging in again."
|
||||
/>
|
||||
|
||||
<Modal v-if="showNameDialog" @close="showNameDialog = false">
|
||||
<h3>Edit Display Name</h3>
|
||||
<form @submit.prevent="saveName" class="modal-form">
|
||||
<NameEditForm
|
||||
label="Display Name"
|
||||
v-model="newName"
|
||||
:busy="saving"
|
||||
@cancel="showNameDialog = false"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
<Modal v-if="showEditDialog" @close="showEditDialog = false">
|
||||
<h3>Edit Profile</h3>
|
||||
<form @submit.prevent="saveProfile" class="modal-form">
|
||||
<div class="profile-edit-form">
|
||||
<label for="edit-display-name">Display Name
|
||||
<input id="edit-display-name" type="text" v-model="editName" :disabled="saving" required />
|
||||
</label>
|
||||
<label for="edit-email">Email
|
||||
<input id="edit-email" type="email" v-model="editEmail" :disabled="saving" placeholder="user@example.com" />
|
||||
</label>
|
||||
<label for="edit-username">Preferred Username
|
||||
<input id="edit-username" type="text" v-model="editUsername" :disabled="saving" placeholder="username" />
|
||||
</label>
|
||||
<label for="edit-telephone">Telephone
|
||||
<input id="edit-telephone" type="tel" v-model="editTelephone" :disabled="saving" placeholder="+1-555-123-4567" />
|
||||
</label>
|
||||
<div v-if="editError" class="error small">{{ editError }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" @click="showEditDialog = false" :disabled="saving">Cancel</button>
|
||||
<button type="submit" class="btn-primary" :disabled="saving" data-nav-primary>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -162,7 +147,6 @@ import CredentialList from '@/components/CredentialList.vue'
|
||||
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import RemoteAuthPermit from '@/components/RemoteAuthPermit.vue'
|
||||
@@ -175,16 +159,14 @@ 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 showEditDialog = ref(false)
|
||||
const showRegLink = ref(false)
|
||||
const newName = ref('')
|
||||
const newEmail = ref('')
|
||||
const newPreferredUsername = ref('')
|
||||
const editName = ref('')
|
||||
const editEmail = ref('')
|
||||
const editUsername = ref('')
|
||||
const editTelephone = ref('')
|
||||
const saving = ref(false)
|
||||
const emailError = ref('')
|
||||
const preferredUsernameError = ref('')
|
||||
const editError = ref('')
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const showDeviceInfo = ref(false)
|
||||
@@ -198,20 +180,16 @@ const userBasicInfo = ref(null)
|
||||
const userInfoSection = ref(null)
|
||||
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => showNameDialog.value || showEmailDialog.value || showPreferredUsernameDialog.value || showRegLink.value)
|
||||
const hasActiveModal = computed(() => showEditDialog.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 = ''
|
||||
}
|
||||
watch(showEditDialog, (open) => {
|
||||
if (!open) return
|
||||
const user = authStore.userInfo?.ctx?.user
|
||||
editName.value = user?.display_name ?? ''
|
||||
editEmail.value = user?.email ?? ''
|
||||
editUsername.value = user?.preferred_username ?? ''
|
||||
editTelephone.value = user?.telephone ?? ''
|
||||
editError.value = ''
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -385,7 +363,7 @@ const terminateSession = async (session) => {
|
||||
|
||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||
const logout = async () => { await authStore.logout() }
|
||||
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true }
|
||||
const openEditDialog = () => { showEditDialog.value = true }
|
||||
const isAdmin = computed(() => {
|
||||
const perms = authStore.userInfo?.ctx.permissions
|
||||
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||
@@ -409,57 +387,34 @@ const useWideLayout = computed(() => {
|
||||
})
|
||||
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
||||
|
||||
const saveName = async () => {
|
||||
const name = newName.value.trim()
|
||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||
const saveProfile = async () => {
|
||||
const name = editName.value.trim()
|
||||
if (!name) { editError.value = 'Name cannot be empty'; return }
|
||||
const user = authStore.userInfo.ctx.user
|
||||
const emailVal = editEmail.value.trim() || null
|
||||
const usernameVal = editUsername.value.trim() || null
|
||||
const telephoneVal = editTelephone.value.trim() || null
|
||||
try {
|
||||
editError.value = ''
|
||||
saving.value = true
|
||||
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
|
||||
showNameDialog.value = false
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
||||
} 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)
|
||||
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)
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||
}
|
||||
showEditDialog.value = false
|
||||
} 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 }
|
||||
editError.value = e.message || 'Failed to update profile'
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -474,4 +429,5 @@ const savePreferredUsername = async () => {
|
||||
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
||||
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
|
||||
.profile-edit-form { display: flex; flex-direction: column; gap: var(--space-md); }
|
||||
</style>
|
||||
|
||||
@@ -771,7 +771,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
<template>
|
||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||
<h3 class="user-name-heading">
|
||||
<span class="icon">👤</span>
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('editName')" title="Edit name">✏️</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<span class="date-label"><strong>Visits:</strong></span>
|
||||
<span class="date-value">{{ visits || 0 }}</span>
|
||||
<span class="date-label"><strong>Registered:</strong></span>
|
||||
<span class="date-value">{{ formatDate(createdAt) }}</span>
|
||||
<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 class="user-info-content">
|
||||
<div class="user-picture">
|
||||
<span>👤</span>
|
||||
</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>
|
||||
<h3 class="user-name-heading">
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
<button v-if="canEdit && updateEndpoint" class="mini-btn" @click="emit('edit')" title="Edit profile">✏️</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div v-if="orgDisplayName || roleName" class="org-role-sub">
|
||||
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
|
||||
<div class="role-line" v-if="roleName">{{ roleName }}</div>
|
||||
</div>
|
||||
<div class="info-fields-block">
|
||||
<div v-if="preferred_username" class="contact-item">🆔 {{ preferred_username }}</div>
|
||||
<a v-if="email" :href="`mailto:${email}`" class="contact-link">✉️ {{ email }}</a>
|
||||
<a v-if="telephone" :href="`tel:${telephone}`" class="contact-link">📞 {{ telephone }}</a>
|
||||
</div>
|
||||
<div class="info-line">
|
||||
<span v-if="visits">
|
||||
<span class="info-date">{{ formatDate(createdAt) }}</span>
|
||||
<span class="info-punct"> – </span>
|
||||
<span class="info-date">{{ formatDate(lastSeen) }}</span>
|
||||
<span class="info-punct"> ×</span>
|
||||
<span class="info-count">{{ visits }}</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="info-label">Created </span>
|
||||
<span class="info-date">{{ formatDate(createdAt) }}</span>
|
||||
<span class="info-punct"> — Never signed in</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="user-info-extra">
|
||||
@@ -38,7 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
@@ -46,6 +49,7 @@ const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
email: { type: String, default: null },
|
||||
preferred_username: { type: String, default: null },
|
||||
telephone: { type: String, default: null },
|
||||
visits: { type: [Number, String], default: 0 },
|
||||
createdAt: { type: [String, Number, Date], default: null },
|
||||
lastSeen: { type: [String, Number, Date], default: null },
|
||||
@@ -56,7 +60,7 @@ const props = defineProps({
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'editName', 'editEmail', 'editPreferredUsername'])
|
||||
const emit = defineEmits(['saved', 'edit'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
@@ -64,62 +68,56 @@ const userLoaded = computed(() => !!props.name)
|
||||
|
||||
<style scoped>
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr 2fr;
|
||||
grid-template-columns: minmax(0, 1fr) 14rem;
|
||||
grid-template-areas:
|
||||
"heading heading extra"
|
||||
"org org extra"
|
||||
"label1 value1 extra"
|
||||
"label2 value2 extra"
|
||||
"label3 value3 extra"
|
||||
"oidc oidc extra";
|
||||
"content extra";
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-info:not(.has-extra) {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"heading heading"
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3"
|
||||
"oidc oidc";
|
||||
"content";
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.user-info.has-extra {
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"heading heading"
|
||||
"org org"
|
||||
"label1 value1"
|
||||
"label2 value2"
|
||||
"label3 value3"
|
||||
"extra extra";
|
||||
"content"
|
||||
"extra";
|
||||
}
|
||||
}
|
||||
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
|
||||
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
|
||||
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
|
||||
.info-label:nth-of-type(1) { grid-area: label1; }
|
||||
.info-value:nth-of-type(2) { grid-area: value1; }
|
||||
.info-label:nth-of-type(3) { grid-area: label2; }
|
||||
.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; }
|
||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.name-input { width: auto; flex: 1 1 140px; min-width: 120px; padding: 6px 8px; font-size: 0.9em; border: 1px solid var(--color-border-strong); border-radius: 6px; background: var(--color-surface); color: var(--color-text); }
|
||||
.user-name-heading .name-input { width: auto; }
|
||||
.name-input:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); }
|
||||
.user-info-content {
|
||||
grid-area: content;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"picture heading fields"
|
||||
"picture org fields"
|
||||
". info info";
|
||||
gap: 0 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.role-line { font-size: .65rem; color: var(--color-text-muted); line-height: 1.1; }
|
||||
.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); }
|
||||
.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; }
|
||||
.info-punct { color: var(--color-text-muted) !important; }
|
||||
.info-count { color: var(--color-text-muted) !important; }
|
||||
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
|
||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
|
||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
|
||||
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
|
||||
@@ -65,6 +65,7 @@ from paskia.db.operations import (
|
||||
update_user_email,
|
||||
update_user_preferred_username,
|
||||
update_user_role,
|
||||
update_user_telephone,
|
||||
update_user_theme,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
@@ -151,6 +152,7 @@ __all__ = [
|
||||
"update_user_email",
|
||||
"update_user_preferred_username",
|
||||
"update_user_role",
|
||||
"update_user_telephone",
|
||||
"update_user_theme",
|
||||
# OIDC
|
||||
"create_oid_client",
|
||||
|
||||
@@ -329,6 +329,27 @@ def update_user_preferred_username(
|
||||
_db.users[uuid].preferred_username = preferred_username
|
||||
|
||||
|
||||
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()
|
||||
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
|
||||
|
||||
|
||||
def update_user_role(
|
||||
uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
|
||||
@@ -211,6 +211,7 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
theme: str = "" # "" or "auto" = OS default, "light", "dark"
|
||||
email: str | None = None # OIDC email claim
|
||||
preferred_username: str | None = None # OIDC preferred_username claim
|
||||
telephone: str | None = None # Telephone number
|
||||
|
||||
def __post_init__(self):
|
||||
if not hasattr(self, "uuid"):
|
||||
|
||||
@@ -579,6 +579,9 @@ async def admin_get_user_detail(
|
||||
"visits": user.visits,
|
||||
"created_at": user.created_at,
|
||||
"last_seen": user.last_seen,
|
||||
"email": user.email,
|
||||
"preferred_username": user.preferred_username,
|
||||
"telephone": user.telephone,
|
||||
"credentials": [
|
||||
{
|
||||
"credential": c.uuid,
|
||||
@@ -634,6 +637,84 @@ async def admin_update_user_display_name(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
|
||||
@app.delete("/users/{user_uuid}")
|
||||
async def admin_delete_user(
|
||||
user_uuid: UUID,
|
||||
|
||||
@@ -120,6 +120,26 @@ async def user_update_preferred_username(
|
||||
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"}
|
||||
|
||||
|
||||
@app.post("/logout-all")
|
||||
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
|
||||
@@ -21,6 +21,8 @@ def build_session_context(ctx: SessionContext) -> dict:
|
||||
result["user"]["email"] = ctx.user.email
|
||||
if ctx.user.preferred_username:
|
||||
result["user"]["preferred_username"] = ctx.user.preferred_username
|
||||
if ctx.user.telephone:
|
||||
result["user"]["telephone"] = ctx.user.telephone
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user