Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f51f8501d | ||
|
|
a7e6eb7341 | ||
|
|
1800dc12ae | ||
|
|
cc55474e62 | ||
|
|
8ac2c8e5fa |
@@ -1,5 +1,7 @@
|
|||||||
# Paskia
|
# Paskia
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
||||||
|
|
||||||
## What is Paskia?
|
## What is Paskia?
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { initThemeFromCache } from '@/utils/theme'
|
||||||
|
initThemeFromCache()
|
||||||
|
|
||||||
import '@/assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { initThemeFromCache } from '@/utils/theme'
|
||||||
|
initThemeFromCache()
|
||||||
|
|
||||||
import '@/assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||||
|
|
||||||
// Check if this is a remote auth URL: /auth/{token}
|
// Check if this is a remote auth URL: /auth/{token}
|
||||||
@@ -30,14 +30,9 @@ function extractRemoteToken() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect mode from URL hash fragment
|
// Parse URL hash fragment
|
||||||
const authMode = computed(() => {
|
const hashParams = new URLSearchParams(window.location.hash.slice(1))
|
||||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
const authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
|
||||||
const mode = params.get('mode')
|
|
||||||
if (mode === 'reauth') return 'reauth'
|
|
||||||
if (mode === 'forbidden') return 'forbidden'
|
|
||||||
return 'login'
|
|
||||||
})
|
|
||||||
|
|
||||||
function postToParent(message) {
|
function postToParent(message) {
|
||||||
if (window.parent && window.parent !== window) {
|
if (window.parent && window.parent !== window) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import './theme.js'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import RestrictedApi from './RestrictedApi.vue'
|
import RestrictedApi from './RestrictedApi.vue'
|
||||||
import '@/assets/style.css'
|
import '@/assets/style.css'
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Early theme for restricted app - first URL param wins, then localStorage
|
||||||
|
import { themeColors, applyTheme, getCachedTheme } from '@/utils/theme.js'
|
||||||
|
|
||||||
|
function getTheme() {
|
||||||
|
const params = new URLSearchParams(location.hash.slice(1))
|
||||||
|
return params.get('theme') || getCachedTheme() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use .surface selector to preserve transparent background
|
||||||
|
applyTheme(getTheme(), '.surface')
|
||||||
|
addEventListener('hashchange', () => applyTheme(getTheme(), '.surface'))
|
||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -78,7 +78,6 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color-scheme: light dark;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -132,6 +131,7 @@ a:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-root {
|
.view-root {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root" data-view="profile">
|
<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">
|
<header class="view-header">
|
||||||
<h1>User Profile</h1>
|
<h1>User Profile</h1>
|
||||||
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||||
@@ -129,6 +139,7 @@ import passkey from '@/utils/passkey'
|
|||||||
import { goBack } from '@/utils/helpers'
|
import { goBack } from '@/utils/helpers'
|
||||||
import { apiJson } from 'paskia'
|
import { apiJson } from 'paskia'
|
||||||
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
|
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const updateInterval = ref(null)
|
const updateInterval = ref(null)
|
||||||
@@ -148,6 +159,22 @@ const breadcrumbs = ref(null)
|
|||||||
const userBasicInfo = ref(null)
|
const userBasicInfo = ref(null)
|
||||||
const userInfoSection = 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)
|
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||||
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
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; }
|
.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-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-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
|
||||||
.remote-auth-description {
|
.remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
|
||||||
font-size: 0.75rem;
|
.theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
|
||||||
color: var(--color-text-muted);
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { register, authenticate } from '@/utils/passkey'
|
import { register, authenticate } from '@/utils/passkey'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
import { apiJson } from 'paskia'
|
import { apiJson } from 'paskia'
|
||||||
|
import { updateThemeFromSession } from '@/utils/theme'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -86,6 +87,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
try {
|
try {
|
||||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||||
|
updateThemeFromSession(this.userInfo?.ctx)
|
||||||
console.log('User info loaded:', this.userInfo)
|
console.log('User info loaded:', this.userInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Theme override utilities - shared across apps
|
||||||
|
// User preference or URL hash can force light/dark mode
|
||||||
|
|
||||||
|
export 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'
|
||||||
|
const TRANSITION_ID = 'theme-transition'
|
||||||
|
const STORAGE_KEY = 'paskia-theme'
|
||||||
|
|
||||||
|
/** Apply theme override CSS - selector targets .surface for restricted app, :root for main apps */
|
||||||
|
export function applyTheme(theme, selector = ':root', animate = false) {
|
||||||
|
// Add temporary transition for smooth theme change
|
||||||
|
if (animate) {
|
||||||
|
let transitionStyle = document.getElementById(TRANSITION_ID)
|
||||||
|
if (!transitionStyle) {
|
||||||
|
transitionStyle = document.createElement('style')
|
||||||
|
transitionStyle.id = TRANSITION_ID
|
||||||
|
transitionStyle.textContent = '*, *::before, *::after { transition: background-color 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s !important; }'
|
||||||
|
document.head.appendChild(transitionStyle)
|
||||||
|
}
|
||||||
|
setTimeout(() => document.getElementById(TRANSITION_ID)?.remove(), 350)
|
||||||
|
}
|
||||||
|
document.getElementById(STYLE_ID)?.remove()
|
||||||
|
if (theme && themeColors[theme]) {
|
||||||
|
const css = `${selector} { ${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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get theme from localStorage cache */
|
||||||
|
export function getCachedTheme() {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache theme in localStorage */
|
||||||
|
export function setCachedTheme(theme) {
|
||||||
|
if (theme) localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
else localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialize theme from user preference (with localStorage cache for fast load) */
|
||||||
|
export function initThemeFromCache() {
|
||||||
|
applyTheme(getCachedTheme())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update theme from session context (call after login/session load) */
|
||||||
|
export function updateThemeFromSession(ctx, animate = false) {
|
||||||
|
const theme = ctx?.user?.theme || ''
|
||||||
|
setCachedTheme(theme)
|
||||||
|
applyTheme(theme, ':root', animate)
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Paskia
|
# Paskia
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
|
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ from paskia.db.operations import (
|
|||||||
update_user_display_name,
|
update_user_display_name,
|
||||||
update_user_role,
|
update_user_role,
|
||||||
update_user_role_in_organization,
|
update_user_role_in_organization,
|
||||||
|
update_user_theme,
|
||||||
)
|
)
|
||||||
from paskia.db.structs import (
|
from paskia.db.structs import (
|
||||||
DB,
|
DB,
|
||||||
@@ -147,4 +148,5 @@ __all__ = [
|
|||||||
"update_user_display_name",
|
"update_user_display_name",
|
||||||
"update_user_role",
|
"update_user_role",
|
||||||
"update_user_role_in_organization",
|
"update_user_role_in_organization",
|
||||||
|
"update_user_theme",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -352,6 +352,23 @@ def update_user_display_name(
|
|||||||
_db.users[uuid].display_name = 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(
|
def update_user_role(
|
||||||
uuid: UUID,
|
uuid: UUID,
|
||||||
role_uuid: UUID,
|
role_uuid: UUID,
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
class User(msgspec.Struct, dict=True):
|
class User(msgspec.Struct, dict=True, omit_defaults=True):
|
||||||
"""User data structure.
|
"""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)
|
Immutable fields: created_at (set at creation, never modified)
|
||||||
uuid is derived from created_at using uuid7.
|
uuid is derived from created_at using uuid7.
|
||||||
"""
|
"""
|
||||||
@@ -160,6 +160,7 @@ class User(msgspec.Struct, dict=True):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
last_seen: datetime | None = None
|
last_seen: datetime | None = None
|
||||||
visits: int = 0
|
visits: int = 0
|
||||||
|
theme: str = "" # "" or "auto" = OS default, "light", "dark"
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not hasattr(self, "uuid"):
|
if not hasattr(self, "uuid"):
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ async def verify(
|
|||||||
mode="login",
|
mode="login",
|
||||||
clear_session=True,
|
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
|
# Check max_age requirement if specified
|
||||||
if max_age:
|
if max_age:
|
||||||
try:
|
try:
|
||||||
@@ -88,6 +91,7 @@ async def verify(
|
|||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Additional authentication required",
|
detail="Additional authentication required",
|
||||||
mode="reauth",
|
mode="reauth",
|
||||||
|
theme=user_theme,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Invalid max_age format - log but don't fail the request
|
# 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)
|
ctx, perm, missing, require_all=(match == permutil.has_all)
|
||||||
)
|
)
|
||||||
raise AuthException(
|
raise AuthException(
|
||||||
status_code=403, mode="forbidden", detail="Permission required"
|
status_code=403,
|
||||||
|
mode="forbidden",
|
||||||
|
detail="Permission required",
|
||||||
|
theme=user_theme,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ _access_logger = logging.getLogger("paskia.access")
|
|||||||
frontend = Frontend(
|
frontend = Frontend(
|
||||||
Path(__file__).parent.parent / "frontend-build",
|
Path(__file__).parent.parent / "frontend-build",
|
||||||
cached=["/auth/assets/"],
|
cached=["/auth/assets/"],
|
||||||
|
favicon="/paskia.webp",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -135,6 +136,11 @@ async def examples_page():
|
|||||||
return FileResponse(index_file, media_type="text/html")
|
return FileResponse(index_file, media_type="text/html")
|
||||||
|
|
||||||
|
|
||||||
|
# Frontend static files - must be before /{token} catch-all routes
|
||||||
|
# (actual routes registered during lifespan after frontend.load())
|
||||||
|
frontend.route(app, "/")
|
||||||
|
|
||||||
|
|
||||||
# Note: this catch-all handler must be the last route defined
|
# Note: this catch-all handler must be the last route defined
|
||||||
@app.get("/{token}")
|
@app.get("/{token}")
|
||||||
@app.get("/auth/{token}")
|
@app.get("/auth/{token}")
|
||||||
@@ -147,7 +153,3 @@ async def token_link(token: str):
|
|||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
return Response(*await vitedev.read("/int/reset/index.html"))
|
return Response(*await vitedev.read("/int/reset/index.html"))
|
||||||
|
|
||||||
|
|
||||||
# Final catch-all route for frontend files (keep at end of file)
|
|
||||||
frontend.route(app, "/")
|
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ async def user_update_display_name(
|
|||||||
return {"status": "ok"}
|
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")
|
@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:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Startup configuration box formatting utilities."""
|
"""Startup configuration box formatting utilities."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -11,12 +12,26 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||||
|
|
||||||
|
# ANSI color codes
|
||||||
|
RESET = "\033[0m"
|
||||||
|
YELLOW = "\033[33m" # Dark yellow
|
||||||
|
BRIGHT_YELLOW = "\033[93m" # Bright yellow
|
||||||
|
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_len(text: str) -> int:
|
||||||
|
"""Calculate visible length of text, ignoring ANSI escape codes."""
|
||||||
|
return len(re.sub(r"\033\[[0-9;]*m", "", text))
|
||||||
|
|
||||||
|
|
||||||
def line(text: str = "") -> str:
|
def line(text: str = "") -> str:
|
||||||
"""Format a line inside the box with proper padding, truncating if needed."""
|
"""Format a line inside the box with proper padding, truncating if needed."""
|
||||||
if len(text) > BOX_WIDTH:
|
visible = _visible_len(text)
|
||||||
|
if visible > BOX_WIDTH:
|
||||||
text = text[: BOX_WIDTH - 1] + "…"
|
text = text[: BOX_WIDTH - 1] + "…"
|
||||||
return f"┃ {text:<{BOX_WIDTH}} ┃\n"
|
visible = BOX_WIDTH
|
||||||
|
padding = BOX_WIDTH - visible
|
||||||
|
return f"┃ {text}{' ' * padding} ┃\n"
|
||||||
|
|
||||||
|
|
||||||
def top() -> str:
|
def top() -> str:
|
||||||
@@ -29,12 +44,25 @@ def bottom() -> str:
|
|||||||
|
|
||||||
def print_startup_config(config: "PaskiaConfig") -> None:
|
def print_startup_config(config: "PaskiaConfig") -> None:
|
||||||
"""Print server configuration on startup."""
|
"""Print server configuration on startup."""
|
||||||
|
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||||
|
Y = YELLOW # Dark yellow for main body
|
||||||
|
B = BRIGHT_YELLOW # Bright yellow for highlights/edges
|
||||||
|
W = BRIGHT_WHITE # Bold white for URL
|
||||||
|
R = RESET
|
||||||
|
|
||||||
lines = [top()]
|
lines = [top()]
|
||||||
lines.append(line(" ▄▄▄▄▄"))
|
lines.append(line(f" {B}▄▄▄▄▄{R}"))
|
||||||
lines.append(line("█ █ Paskia " + __version__))
|
lines.append(line(f"{B}█{Y} {B}█{R} Paskia " + __version__))
|
||||||
lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄"))
|
lines.append(line(f"{B}█{Y} {B}█{Y}▄▄▄▄▄▄▄▄▄▄▄▄{R}"))
|
||||||
lines.append(line("█ █▀▀▀▀█▀▀█▀▀█ " + config.site_url + config.site_path))
|
lines.append(
|
||||||
lines.append(line(" ▀▀▀▀▀"))
|
line(
|
||||||
|
f"{B}█{Y} {B}█{Y}▀▀▀▀{B}█{Y}▀▀{B}█{Y}▀▀{B}█{R} {W}"
|
||||||
|
+ config.site_url
|
||||||
|
+ config.site_path
|
||||||
|
+ R
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.append(line(f" {Y}▀▀▀▀▀{R}"))
|
||||||
|
|
||||||
# Format auth host section
|
# Format auth host section
|
||||||
if config.auth_host:
|
if config.auth_host:
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ from paskia.util.apistructs import ApiSession
|
|||||||
|
|
||||||
def build_session_context(ctx: SessionContext) -> dict:
|
def build_session_context(ctx: SessionContext) -> dict:
|
||||||
"""Build session context dict from SessionContext."""
|
"""Build session context dict from SessionContext."""
|
||||||
return {
|
result = {
|
||||||
"user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
|
"user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
|
||||||
"org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
|
"org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
|
||||||
"role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
|
"role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
|
||||||
"permissions": [p.scope for p in ctx.permissions],
|
"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(
|
async def build_user_info(
|
||||||
|
|||||||
Reference in New Issue
Block a user