Light/dark selection in user profile, if set this is preferred on the whole system, together with app overrides (the first one on the URL wins).

This commit is contained in:
2026-01-30 23:31:06 +00:00
parent cc55474e62
commit 1800dc12ae
12 changed files with 111 additions and 76 deletions
+3
View File
@@ -1,3 +1,6 @@
import { initThemeFromCache } from '@/utils/theme'
initThemeFromCache()
import '@/assets/style.css'
import { createApp } from 'vue'
+3
View File
@@ -1,3 +1,6 @@
import { initThemeFromCache } from '@/utils/theme'
initThemeFromCache()
import '@/assets/style.css'
import { createApp } from 'vue'
+7 -68
View File
@@ -1,72 +1,11 @@
// Early theme override script - runs before Vue to prevent flicker
// Parses ?theme=light or ?theme=dark from URL hash and injects CSS overrides
// Early theme for restricted app - first URL param wins, then localStorage
import { themeColors, applyTheme, getCachedTheme } from '@/utils/theme.js'
const themeColors = {
light: {
'color-canvas': '#ffffff',
'color-surface': '#eff6ff',
'color-surface-subtle': '#dbeafe',
'color-border': '#2563eb',
'color-border-strong': '#1e40af',
'color-heading': '#1e3a8a',
'color-text': '#1e293b',
'color-text-muted': '#475569',
'color-link': '#1d4ed8',
'color-link-hover': '#1e40af',
'color-accent': '#2563eb',
'color-accent-strong': '#1e40af',
'color-accent-contrast': '#ffffff',
'color-success-text': '#166534',
'color-success-bg': '#dcfce7',
'color-error-text': '#b91c1c',
'color-error-bg': '#fee2e2',
'color-info-text': '#1e40af',
'color-info-bg': '#dbeafe',
'color-danger': '#dc2626',
'shadow-soft': '0 10px 30px rgba(30, 64, 175, 0.15)',
},
dark: {
'color-canvas': '#0f172a',
'color-surface': '#141b2f',
'color-surface-subtle': '#1b243b',
'color-border': '#25304a',
'color-border-strong': '#3d4d6b',
'color-heading': '#fff',
'color-text': '#e2e8f0',
'color-text-muted': '#94a3b8',
'color-link': '#60a5fa',
'color-link-hover': '#93c5fd',
'color-accent': '#60a5fa',
'color-accent-strong': '#3b82f6',
'color-accent-contrast': '#0b1120',
'color-success-text': '#34d399',
'color-success-bg': '#1a4d2e',
'color-error-text': '#fca5a5',
'color-error-bg': '#4a1f1f',
'color-info-text': '#bae6fd',
'color-info-bg': '#1e3a5f',
'color-danger': '#f87171',
'shadow-soft': '0 0 0 #000000',
}
}
const STYLE_ID = 'theme-override'
function applyTheme() {
function getTheme() {
const params = new URLSearchParams(location.hash.slice(1))
const theme = params.get('theme')
// Remove existing override
document.getElementById(STYLE_ID)?.remove()
if (theme && themeColors[theme]) {
const css = `.surface { ${Object.entries(themeColors[theme]).map(([k, v]) => `--${k}: ${v}`).join('; ')}; }`
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = css
document.head.appendChild(style)
}
return params.get('theme') || getCachedTheme() || ''
}
applyTheme()
addEventListener('hashchange', applyTheme)
// Use .surface selector to preserve transparent background
applyTheme(getTheme(), '.surface')
addEventListener('hashchange', () => applyTheme(getTheme(), '.surface'))
+1
View File
@@ -131,6 +131,7 @@ a:focus-visible {
}
.view-root {
position: relative;
flex: 1;
width: 100%;
display: flex;
+39 -4
View File
@@ -1,5 +1,15 @@
<template>
<section class="view-root" data-view="profile">
<div class="theme-toggle">
<button class="theme-btn" @click="themeMenuOpen = !themeMenuOpen" :title="themeTitle">
{{ themeEmoji }}
</button>
<div v-if="themeMenuOpen" class="theme-menu" @click="themeMenuOpen = false">
<button class="theme-option top" :class="{ active: selectedTheme === '' }" @click.stop="setTheme('')" title="Auto">🌓</button>
<button class="theme-option left" :class="{ active: selectedTheme === 'light' }" @click.stop="setTheme('light')" title="Light"></button>
<button class="theme-option right" :class="{ active: selectedTheme === 'dark' }" @click.stop="setTheme('dark')" title="Dark">🌙</button>
</div>
</div>
<header class="view-header">
<h1>User Profile</h1>
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
@@ -129,6 +139,7 @@ import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers'
import { apiJson } from 'paskia'
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme'
const authStore = useAuthStore()
const updateInterval = ref(null)
@@ -148,6 +159,22 @@ const breadcrumbs = ref(null)
const userBasicInfo = ref(null)
const userInfoSection = ref(null)
// Theme preference
const selectedTheme = ref('')
const themeMenuOpen = ref(false)
const themeEmoji = computed(() => ({ '': '🌓', light: '', dark: '🌙' })[selectedTheme.value] || '🌓')
const themeTitle = computed(() => ({ '': 'Auto (system)', light: 'Light mode', dark: 'Dark mode' })[selectedTheme.value] || 'Theme')
watch(() => authStore.userInfo?.ctx?.user?.theme, (t) => { selectedTheme.value = t || '' }, { immediate: true })
function setTheme(theme) {
selectedTheme.value = theme
themeMenuOpen.value = false
// Apply immediately for instant feedback
updateThemeFromSession({ user: { theme } }, true)
// Save to server in background
apiJson('/auth/api/user/theme', { method: 'PATCH', body: { theme } })
.catch(e => authStore.showMessage(e.message, 'error'))
}
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
@@ -352,8 +379,16 @@ const saveName = async () => {
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
.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);
}
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
.theme-btn { background: none; border: none; padding: 0.25rem; font-size: 1.25rem; cursor: pointer; opacity: 0.5; transition: opacity 0.15s; }
.theme-btn:hover { opacity: 0.8; }
.theme-menu { position: absolute; top: 100%; right: 0; width: 5rem; height: 4rem; margin-top: 0.25rem; }
.theme-option { position: absolute; background: none; border: none; font-size: 1.25rem; cursor: pointer; opacity: 0.5; padding: 0.25rem; border-radius: var(--radius-sm); transition: opacity 0.15s, transform 0.15s; }
.theme-option:hover { opacity: 1; transform: scale(1.2); }
.theme-option.active { opacity: 1; }
.theme-option.top { top: 0; left: 50%; transform: translateX(-50%); }
.theme-option.top:hover { transform: translateX(-50%) scale(1.2); }
.theme-option.left { bottom: 0; left: 0; }
.theme-option.right { bottom: 0; right: 0; }
</style>
+2
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { apiJson } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', {
state: () => ({
@@ -86,6 +87,7 @@ export const useAuthStore = defineStore('auth', {
async loadUserInfo() {
try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
updateThemeFromSession(this.userInfo?.ctx)
console.log('User info loaded:', this.userInfo)
} catch (error) {
// Suppress toast for 401/403 errors - the auth iframe will handle these
+2
View File
@@ -64,6 +64,7 @@ from paskia.db.operations import (
update_user_display_name,
update_user_role,
update_user_role_in_organization,
update_user_theme,
)
from paskia.db.structs import (
DB,
@@ -147,4 +148,5 @@ __all__ = [
"update_user_display_name",
"update_user_role",
"update_user_role_in_organization",
"update_user_theme",
]
+17
View File
@@ -352,6 +352,23 @@ def update_user_display_name(
_db.users[uuid].display_name = display_name
def update_user_theme(
uuid: UUID,
theme: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update user theme preference ('' for auto, 'light', 'dark')."""
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
def update_user_role(
uuid: UUID,
role_uuid: UUID,
+3 -2
View File
@@ -147,10 +147,10 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
return role
class User(msgspec.Struct, dict=True):
class User(msgspec.Struct, dict=True, omit_defaults=True):
"""User data structure.
Mutable fields: display_name, role_uuid, last_seen, visits
Mutable fields: display_name, role_uuid, last_seen, visits, theme
Immutable fields: created_at (set at creation, never modified)
uuid is derived from created_at using uuid7.
"""
@@ -160,6 +160,7 @@ class User(msgspec.Struct, dict=True):
created_at: datetime
last_seen: datetime | None = None
visits: int = 0
theme: str = "" # "" or "auto" = OS default, "light", "dark"
def __post_init__(self):
if not hasattr(self, "uuid"):
+8 -1
View File
@@ -80,6 +80,9 @@ async def verify(
mode="login",
clear_session=True,
)
# User's theme preference for iframe (only if explicitly set)
user_theme = ctx.user.theme if ctx.user.theme else None
# Check max_age requirement if specified
if max_age:
try:
@@ -88,6 +91,7 @@ async def verify(
status_code=401,
detail="Additional authentication required",
mode="reauth",
theme=user_theme,
)
except ValueError as e:
# Invalid max_age format - log but don't fail the request
@@ -104,7 +108,10 @@ async def verify(
ctx, perm, missing, require_all=(match == permutil.has_all)
)
raise AuthException(
status_code=403, mode="forbidden", detail="Permission required"
status_code=403,
mode="forbidden",
detail="Permission required",
theme=user_theme,
)
return ctx
+22
View File
@@ -57,6 +57,28 @@ async def user_update_display_name(
return {"status": "ok"}
@app.patch("/theme")
async def user_update_theme(
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"
)
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.post("/logout-all")
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth:
+4 -1
View File
@@ -9,12 +9,15 @@ from paskia.util.apistructs import ApiSession
def build_session_context(ctx: SessionContext) -> dict:
"""Build session context dict from SessionContext."""
return {
result = {
"user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
"org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
"role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
"permissions": [p.scope for p in ctx.permissions],
}
if ctx.user.theme:
result["user"]["theme"] = ctx.user.theme
return result
async def build_user_info(