Compare commits

...
7 Commits
25 changed files with 323 additions and 75 deletions
+13 -10
View File
@@ -1,5 +1,7 @@
# 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.
## 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:
```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`:
@@ -42,19 +44,20 @@ uv tool install paskia
## 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
paskia serve [options]
paskia [options]
paskia reset [user] # Generate passkey reset link
```
| Option | Description | Default |
|--------|-------------|---------|
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
| --rp-id *domain* | Main/top domain | **localhost** |
| --rp-name *"text"* | Name of your company or site | Same as rp-id |
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
## Further Documentation
+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'
+4 -9
View File
@@ -8,7 +8,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue'
// Check if this is a remote auth URL: /auth/{token}
@@ -30,14 +30,9 @@ function extractRemoteToken() {
return null
}
// Detect mode from URL hash fragment
const authMode = computed(() => {
const params = new URLSearchParams(window.location.hash.slice(1))
const mode = params.get('mode')
if (mode === 'reauth') return 'reauth'
if (mode === 'forbidden') return 'forbidden'
return 'login'
})
// Parse URL hash fragment
const hashParams = new URLSearchParams(window.location.hash.slice(1))
const authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
function postToParent(message) {
if (window.parent && window.parent !== window) {
+1
View File
@@ -1,3 +1,4 @@
import './theme.js'
import { createApp } from 'vue'
import RestrictedApi from './RestrictedApi.vue'
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 {
color-scheme: light dark;
overflow: auto;
scrollbar-gutter: stable;
height: 100%;
@@ -132,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
+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](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.
+8 -7
View File
@@ -15,18 +15,18 @@ from paskia.util import hostutil, passphrase
logger = logging.getLogger(__name__)
# Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\
%s
ADMIN_RESET_MESSAGE = """
👤 Admin %s
- 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."""
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
@@ -41,7 +41,7 @@ async def bootstrap_system() -> None:
reset_passphrase = db.bootstrap()
# 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:
@@ -72,6 +72,7 @@ async def check_admin_credentials() -> bool:
if not db.get_user_credential_ids(admin_user.uuid):
# Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!")
token = passphrase.generate()
expiry = authsession.reset_expires()
@@ -81,7 +82,7 @@ async def check_admin_credentials() -> bool:
expiry=expiry,
token_type="admin registration",
)
_log_reset_link("⚠️ Admin user has no credentials!", token)
_log_reset_link(token)
return True
return False
+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",
]
+5 -8
View File
@@ -74,21 +74,18 @@ async def start_background():
_logger.debug("Background task in different event loop, restarting")
_background_task = None
else:
# Task is running in the same event loop - this is an error
raise RuntimeError(
"Background task is already running. "
"start_background() must not be called multiple times in the same event loop."
# Task is already running in same loop - idempotent, just return
# This happens with dual IPv4+IPv6 endpoints sharing the same process
_logger.debug(
"Background task already running in same loop, skipping"
)
except RuntimeError:
raise # Re-raise RuntimeError from above
return
except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None
if _background_task is None:
_background_task = asyncio.create_task(_background_loop())
else:
_logger.debug("Background task already running: %s", _background_task)
async def stop_background():
+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"):
+21 -13
View File
@@ -21,10 +21,10 @@ DEFAULT_PORT = 4401
EPILOG = """\
Examples:
paskia # localhost:4401
paskia :8080 # All interfaces, port 8080
paskia unix:/tmp/paskia.sock
paskia reset [user] # Generate passkey reset link
paskia # localhost:4401
paskia -l :8080 # All interfaces, port 8080
paskia -l /tmp/paskia.sock # Unix socket
paskia reset [user] # Generate passkey reset link
"""
@@ -81,32 +81,40 @@ def main():
epilog=EPILOG,
)
# Primary argument: either host:port or "reset" subcommand
# Subcommand for reset
parser.add_argument(
"hostport",
"command",
nargs="?",
help=(
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
),
help="Command: 'reset' for credential reset, or omit to run server",
)
parser.add_argument(
"reset_query",
nargs="?",
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)
args = parser.parse_args()
# Detect "reset" subcommand (first positional is "reset")
is_reset = args.hostport == "reset"
# Detect "reset" subcommand
is_reset = args.command == "reset"
if is_reset:
endpoints = []
else:
if args.command is not None:
raise SystemExit(f"Unknown command: {args.command}")
# 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
ep = endpoints[0] if endpoints else {}
+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
+6 -4
View File
@@ -26,6 +26,7 @@ _access_logger = logging.getLogger("paskia.access")
frontend = Frontend(
Path(__file__).parent.parent / "frontend-build",
cached=["/auth/assets/"],
favicon="/paskia.webp",
)
@@ -135,6 +136,11 @@ async def examples_page():
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
@app.get("/{token}")
@app.get("/auth/{token}")
@@ -147,7 +153,3 @@ async def token_link(token: str):
raise HTTPException(status_code=404)
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"}
@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:
View File
+35 -7
View File
@@ -1,6 +1,7 @@
"""Startup configuration box formatting utilities."""
import os
import re
from sys import stderr
from typing import TYPE_CHECKING
@@ -11,12 +12,26 @@ if TYPE_CHECKING:
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:
"""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] + ""
return f"{text:<{BOX_WIDTH}}\n"
visible = BOX_WIDTH
padding = BOX_WIDTH - visible
return f"{text}{' ' * padding}\n"
def top() -> str:
@@ -29,12 +44,25 @@ def bottom() -> str:
def print_startup_config(config: "PaskiaConfig") -> None:
"""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.append(line(" ▄▄▄▄▄"))
lines.append(line("█ █ Paskia " + __version__))
lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄"))
lines.append(line("█ █▀▀▀▀█▀▀█▀▀█ " + config.site_url + config.site_path))
lines.append(line(" ▀▀▀▀▀"))
lines.append(line(f" {B}▄▄▄▄▄{R}"))
lines.append(line(f"{B}{Y} {B}{R} Paskia " + __version__))
lines.append(line(f"{B}{Y} {B}{Y}▄▄▄▄▄▄▄▄▄▄▄▄{R}"))
lines.append(
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
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:
"""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(
+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.
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`.
Backend always listens on localhost:4402.
@@ -376,9 +376,15 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
def main():
# Parse optional hostport argument for Vite frontend
# Parse optional listen argument for Vite frontend
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("--rp-id", default="localhost", help="Relying Party ID")
parser.add_argument(
@@ -389,7 +395,7 @@ def main():
# Parse Vite endpoint
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
args.hostport, DEFAULT_VITE_PORT
args.listen, DEFAULT_VITE_PORT
)
if vite_uds:
@@ -446,8 +452,8 @@ def main():
# Start Vite dev server
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)
cmd = ["paskia", f"localhost:{BACKEND_PORT}"]
# Build command with origin args
cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
# Pass through rp-id (always pass, has default)
cmd.extend(["--rp-id", args.rp_id])