OAuth2 OpenID Connect provider support etc. #3

Merged
LeoVasanko merged 65 commits from oidconnect into main 2026-02-18 02:40:27 +00:00
6 changed files with 206 additions and 4 deletions
Showing only changes of commit 7523a49543 - Show all commits
+92 -1
View File
@@ -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>
+24 -3
View File
@@ -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; }
+4
View File
@@ -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
+42
View File
@@ -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,
+40
View File
@@ -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:
+4
View File
@@ -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