Compare commits

...
7 Commits
25 changed files with 323 additions and 75 deletions
+13 -10
View File
@@ -1,5 +1,7 @@
# Paskia # Paskia
![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
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?
@@ -27,12 +29,12 @@ Single Sign-On (SSO): Users register once and authenticate across all applicatio
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish ```fish
uvx paskia serve --rp-id example.com uvx paskia --rp-id example.com
``` ```
On the first run it downloads the software and prints a registration link for the Admin. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id. On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below). For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
For a permanent install of `paskia` CLI command, not needing `uvx`: For a permanent install of `paskia` CLI command, not needing `uvx`:
@@ -42,19 +44,20 @@ uv tool install paskia
## Configuration ## Configuration
There is no config file. Pass only the options on CLI: There is no config file. All settings are passed as CLI options:
```text ```text
paskia serve [options] paskia [options]
paskia reset [user] # Generate passkey reset link
``` ```
| Option | Description | Default | | Option | Description | Default |
|--------|-------------|---------| |--------|-------------|---------|
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** | | -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain | **localhost** | | --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name of your company or site | Same as rp-id | | --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ | | --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.| | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
## Further Documentation ## Further Documentation
+3
View File
@@ -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'
+3
View File
@@ -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'
+4 -9
View File
@@ -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
View File
@@ -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'
+11
View File
@@ -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'))
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+1 -1
View File
@@ -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;
+39 -4
View File
@@ -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
View File
@@ -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
+101
View File
@@ -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
View File
@@ -1,6 +1,6 @@
# Paskia # Paskia
![Paskia](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp) ![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
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.
+8 -7
View File
@@ -15,18 +15,18 @@ from paskia.util import hostutil, passphrase
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Shared log message template for admin reset links # Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\ ADMIN_RESET_MESSAGE = """
%s
👤 Admin %s 👤 Admin %s
- Use this link to register a Passkey for the admin user! - Use this link to register a Passkey for the admin user!
""" """
def _log_reset_link(message: str, passphrase: str) -> str: def _log_reset_link(passphrase: str, message: str | None = None) -> str:
"""Log a reset link message and return the URL.""" """Log a reset link message and return the URL."""
reset_link = hostutil.reset_link_url(passphrase) reset_link = hostutil.reset_link_url(passphrase)
logger.info(ADMIN_RESET_MESSAGE, message, reset_link) if message:
logger.info(message)
logger.info(ADMIN_RESET_MESSAGE, reset_link)
return reset_link return reset_link
@@ -41,7 +41,7 @@ async def bootstrap_system() -> None:
reset_passphrase = db.bootstrap() reset_passphrase = db.bootstrap()
# Log the reset link (this is separate from the transaction log) # Log the reset link (this is separate from the transaction log)
_log_reset_link("✅ Bootstrap completed!", reset_passphrase) _log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
async def check_admin_credentials() -> bool: async def check_admin_credentials() -> bool:
@@ -72,6 +72,7 @@ async def check_admin_credentials() -> bool:
if not db.get_user_credential_ids(admin_user.uuid): if not db.get_user_credential_ids(admin_user.uuid):
# Admin exists but has no credentials, create reset link # Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!")
token = passphrase.generate() token = passphrase.generate()
expiry = authsession.reset_expires() expiry = authsession.reset_expires()
@@ -81,7 +82,7 @@ async def check_admin_credentials() -> bool:
expiry=expiry, expiry=expiry,
token_type="admin registration", token_type="admin registration",
) )
_log_reset_link("⚠️ Admin user has no credentials!", token) _log_reset_link(token)
return True return True
return False return False
+2
View File
@@ -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",
] ]
+5 -8
View File
@@ -74,21 +74,18 @@ async def start_background():
_logger.debug("Background task in different event loop, restarting") _logger.debug("Background task in different event loop, restarting")
_background_task = None _background_task = None
else: else:
# Task is running in the same event loop - this is an error # Task is already running in same loop - idempotent, just return
raise RuntimeError( # This happens with dual IPv4+IPv6 endpoints sharing the same process
"Background task is already running. " _logger.debug(
"start_background() must not be called multiple times in the same event loop." "Background task already running in same loop, skipping"
) )
except RuntimeError: return
raise # Re-raise RuntimeError from above
except Exception as e: except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e) _logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None _background_task = None
if _background_task is None: if _background_task is None:
_background_task = asyncio.create_task(_background_loop()) _background_task = asyncio.create_task(_background_loop())
else:
_logger.debug("Background task already running: %s", _background_task)
async def stop_background(): async def stop_background():
+17
View File
@@ -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,
+3 -2
View File
@@ -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"):
+21 -13
View File
@@ -21,10 +21,10 @@ DEFAULT_PORT = 4401
EPILOG = """\ EPILOG = """\
Examples: Examples:
paskia # localhost:4401 paskia # localhost:4401
paskia :8080 # All interfaces, port 8080 paskia -l :8080 # All interfaces, port 8080
paskia unix:/tmp/paskia.sock paskia -l /tmp/paskia.sock # Unix socket
paskia reset [user] # Generate passkey reset link paskia reset [user] # Generate passkey reset link
""" """
@@ -81,32 +81,40 @@ def main():
epilog=EPILOG, epilog=EPILOG,
) )
# Primary argument: either host:port or "reset" subcommand # Subcommand for reset
parser.add_argument( parser.add_argument(
"hostport", "command",
nargs="?", nargs="?",
help=( help="Command: 'reset' for credential reset, or omit to run server",
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
),
) )
parser.add_argument( parser.add_argument(
"reset_query", "reset_query",
nargs="?", nargs="?",
help="For 'reset' command: user UUID or substring of display name", help="For 'reset' command: user UUID or substring of display name",
) )
parser.add_argument(
"-l",
"--listen",
metavar="LISTEN",
help=(
"Endpoint to listen on (default: localhost:4401). "
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
),
)
add_common_options(parser) add_common_options(parser)
args = parser.parse_args() args = parser.parse_args()
# Detect "reset" subcommand (first positional is "reset") # Detect "reset" subcommand
is_reset = args.hostport == "reset" is_reset = args.command == "reset"
if is_reset: if is_reset:
endpoints = [] endpoints = []
else: else:
if args.command is not None:
raise SystemExit(f"Unknown command: {args.command}")
# Parse endpoint using fastapi_vue.hostutil # Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.hostport, DEFAULT_PORT) endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
# Extract host/port/uds from first endpoint for config display and site_url # Extract host/port/uds from first endpoint for config display and site_url
ep = endpoints[0] if endpoints else {} ep = endpoints[0] if endpoints else {}
+8 -1
View File
@@ -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
+6 -4
View File
@@ -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, "/")
+22
View File
@@ -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:
View File
+35 -7
View File
@@ -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:
+4 -1
View File
@@ -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(
+13 -7
View File
@@ -6,9 +6,9 @@ not from the installed package. It starts both the Vite frontend dev server
and the FastAPI backend with auto-reload enabled. and the FastAPI backend with auto-reload enabled.
Usage: Usage:
uv run scripts/devserver.py [host:port] [options...] uv run scripts/devserver.py [-l host:port] [options...]
The optional host:port argument sets where the Vite frontend listens. The optional -l/--listen argument sets where the Vite frontend listens.
All other options are forwarded to `paskia`. All other options are forwarded to `paskia`.
Backend always listens on localhost:4402. Backend always listens on localhost:4402.
@@ -376,9 +376,15 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
def main(): def main():
# Parse optional hostport argument for Vite frontend # Parse optional listen argument for Vite frontend
parser = argparse.ArgumentParser(add_help=False) parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("hostport", nargs="?", default=None) parser.add_argument(
"-l",
"--listen",
metavar="ENDPOINT",
default=None,
help="Vite frontend endpoint (default: localhost:4403)",
)
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy") parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID") parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
parser.add_argument( parser.add_argument(
@@ -389,7 +395,7 @@ def main():
# Parse Vite endpoint # Parse Vite endpoint
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint( vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
args.hostport, DEFAULT_VITE_PORT args.listen, DEFAULT_VITE_PORT
) )
if vite_uds: if vite_uds:
@@ -446,8 +452,8 @@ def main():
# Start Vite dev server # Start Vite dev server
run_vite(vite_url, vite_host, vite_port, env, args.auth_host) run_vite(vite_url, vite_host, vite_port, env, args.auth_host)
# Build command with origin args (no serve subcommand, host:port is first arg) # Build command with origin args
cmd = ["paskia", f"localhost:{BACKEND_PORT}"] cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
# Pass through rp-id (always pass, has default) # Pass through rp-id (always pass, has default)
cmd.extend(["--rp-id", args.rp_id]) cmd.extend(["--rp-id", args.rp_id])