Add preferred username and email to user data.
This commit is contained in:
@@ -16,6 +16,8 @@
|
|||||||
v-if="authStore.userInfo?.ctx"
|
v-if="authStore.userInfo?.ctx"
|
||||||
ref="userBasicInfo"
|
ref="userBasicInfo"
|
||||||
:name="authStore.userInfo.ctx.user.display_name"
|
: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"
|
:visits="authStore.userInfo.visits"
|
||||||
:created-at="authStore.userInfo.created_at"
|
:created-at="authStore.userInfo.created_at"
|
||||||
:last-seen="authStore.userInfo.last_seen"
|
:last-seen="authStore.userInfo.last_seen"
|
||||||
@@ -23,6 +25,8 @@
|
|||||||
update-endpoint="/auth/api/user/display-name"
|
update-endpoint="/auth/api/user/display-name"
|
||||||
@saved="authStore.loadUserInfo()"
|
@saved="authStore.loadUserInfo()"
|
||||||
@edit-name="openNameDialog"
|
@edit-name="openNameDialog"
|
||||||
|
@edit-email="openEmailDialog"
|
||||||
|
@edit-preferred-username="openPreferredUsernameDialog"
|
||||||
@keydown="handleUserInfoKeydown"
|
@keydown="handleUserInfoKeydown"
|
||||||
>
|
>
|
||||||
<div class="remote-auth-inline">
|
<div class="remote-auth-inline">
|
||||||
@@ -91,6 +95,35 @@
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</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 }]">
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||||
<div class="button-row" ref="logoutButtons">
|
<div class="button-row" ref="logoutButtons">
|
||||||
<button
|
<button
|
||||||
@@ -143,9 +176,15 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
const updateInterval = ref(null)
|
||||||
const showNameDialog = ref(false)
|
const showNameDialog = ref(false)
|
||||||
|
const showEmailDialog = ref(false)
|
||||||
|
const showPreferredUsernameDialog = ref(false)
|
||||||
const showRegLink = ref(false)
|
const showRegLink = ref(false)
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
|
const newEmail = ref('')
|
||||||
|
const newPreferredUsername = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const emailError = ref('')
|
||||||
|
const preferredUsernameError = ref('')
|
||||||
const hoveredCredentialUuid = ref(null)
|
const hoveredCredentialUuid = ref(null)
|
||||||
const hoveredSession = ref(null)
|
const hoveredSession = ref(null)
|
||||||
const showDeviceInfo = ref(false)
|
const showDeviceInfo = ref(false)
|
||||||
@@ -159,9 +198,21 @@ const userBasicInfo = ref(null)
|
|||||||
const userInfoSection = ref(null)
|
const userInfoSection = ref(null)
|
||||||
|
|
||||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
// 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(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(() => {
|
onMounted(() => {
|
||||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
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') }
|
} catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') }
|
||||||
finally { saving.value = false }
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -19,6 +19,18 @@
|
|||||||
<span class="date-label"><strong>Last seen:</strong></span>
|
<span class="date-label"><strong>Last seen:</strong></span>
|
||||||
<span class="date-value">{{ formatDate(lastSeen) }}</span>
|
<span class="date-value">{{ formatDate(lastSeen) }}</span>
|
||||||
</div>
|
</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">
|
<div v-if="$slots.default" class="user-info-extra">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,6 +44,8 @@ import { formatDate } from '@/utils/helpers'
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
|
email: { type: String, default: null },
|
||||||
|
preferred_username: { type: String, default: null },
|
||||||
visits: { type: [Number, String], default: 0 },
|
visits: { type: [Number, String], default: 0 },
|
||||||
createdAt: { type: [String, Number, Date], default: null },
|
createdAt: { type: [String, Number, Date], default: null },
|
||||||
lastSeen: { type: [String, Number, Date], default: null },
|
lastSeen: { type: [String, Number, Date], default: null },
|
||||||
@@ -42,7 +56,7 @@ const props = defineProps({
|
|||||||
roleName: { type: String, default: '' }
|
roleName: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['saved', 'editName'])
|
const emit = defineEmits(['saved', 'editName', 'editEmail', 'editPreferredUsername'])
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const userLoaded = computed(() => !!props.name)
|
const userLoaded = computed(() => !!props.name)
|
||||||
@@ -56,7 +70,8 @@ const userLoaded = computed(() => !!props.name)
|
|||||||
"org org extra"
|
"org org extra"
|
||||||
"label1 value1 extra"
|
"label1 value1 extra"
|
||||||
"label2 value2 extra"
|
"label2 value2 extra"
|
||||||
"label3 value3 extra";
|
"label3 value3 extra"
|
||||||
|
"oidc oidc extra";
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info:not(.has-extra) {
|
.user-info:not(.has-extra) {
|
||||||
@@ -66,7 +81,8 @@ const userLoaded = computed(() => !!props.name)
|
|||||||
"org org"
|
"org org"
|
||||||
"label1 value1"
|
"label1 value1"
|
||||||
"label2 value2"
|
"label2 value2"
|
||||||
"label3 value3";
|
"label3 value3"
|
||||||
|
"oidc oidc";
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -92,6 +108,11 @@ const userLoaded = computed(() => !!props.name)
|
|||||||
.info-value:nth-of-type(4) { grid-area: value2; }
|
.info-value:nth-of-type(4) { grid-area: value2; }
|
||||||
.info-label:nth-of-type(5) { grid-area: label3; }
|
.info-label:nth-of-type(5) { grid-area: label3; }
|
||||||
.info-value:nth-of-type(6) { grid-area: value3; }
|
.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-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 { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
|
||||||
.user-name-row.editing { flex: 1 1 auto; }
|
.user-name-row.editing { flex: 1 1 auto; }
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ 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_preferred_username,
|
||||||
update_user_role,
|
update_user_role,
|
||||||
update_user_theme,
|
update_user_theme,
|
||||||
)
|
)
|
||||||
@@ -146,6 +148,8 @@ __all__ = [
|
|||||||
"update_role_name",
|
"update_role_name",
|
||||||
"update_session",
|
"update_session",
|
||||||
"update_user_display_name",
|
"update_user_display_name",
|
||||||
|
"update_user_email",
|
||||||
|
"update_user_preferred_username",
|
||||||
"update_user_role",
|
"update_user_role",
|
||||||
"update_user_theme",
|
"update_user_theme",
|
||||||
# OIDC
|
# OIDC
|
||||||
|
|||||||
@@ -287,6 +287,48 @@ def update_user_theme(
|
|||||||
_db.users[uuid].theme = 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(
|
def update_user_role(
|
||||||
uuid: UUID,
|
uuid: UUID,
|
||||||
role_uuid: UUID,
|
role_uuid: UUID,
|
||||||
|
|||||||
@@ -80,6 +80,46 @@ async def user_update_theme(
|
|||||||
return {"status": "ok"}
|
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")
|
@app.post("/logout-all")
|
||||||
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||||
if not auth:
|
if not auth:
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ def build_session_context(ctx: SessionContext) -> dict:
|
|||||||
}
|
}
|
||||||
if ctx.user.theme:
|
if ctx.user.theme:
|
||||||
result["user"]["theme"] = 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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user