Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8444b0db8 | ||
|
|
f515daeecd | ||
|
|
5deb57435b | ||
|
|
250189dbe5 | ||
|
|
3469e6fa3f | ||
|
|
c3df6c318c | ||
|
|
615066a2a2 | ||
|
|
70c682b539 | ||
|
|
8444d0399e | ||
|
|
3c5f8694b3 | ||
|
|
871eb149ab | ||
|
|
291a665e21 | ||
|
|
0537b85085 | ||
|
|
af5a48f565 | ||
|
|
632278d4ce | ||
|
|
3faaeee7be | ||
|
|
0f51f8501d | ||
|
|
a7e6eb7341 | ||
|
|
1800dc12ae | ||
|
|
cc55474e62 | ||
|
|
8ac2c8e5fa |
@@ -1,5 +1,7 @@
|
||||
# Paskia
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
@@ -239,12 +239,44 @@ function deleteOrg(org) {
|
||||
|
||||
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
||||
|
||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
if (user.role === targetRoleDisplayName) return
|
||||
function deleteUser(user, userDetail) {
|
||||
const credentialCount = userDetail?.credentials?.length || 0
|
||||
const userUuid = user.uuid
|
||||
const userName = user.display_name
|
||||
const orgUuid = user.org // org UUID is stored in selectedUser
|
||||
|
||||
if (credentialCount === 0) {
|
||||
// No credentials, safe to delete directly
|
||||
performUserDeletion(userUuid, userName, orgUuid)
|
||||
return
|
||||
}
|
||||
|
||||
const passkeys = credentialCount === 1 ? '1 passkey' : `${credentialCount} passkeys`
|
||||
openDialog('confirm', {
|
||||
message: `Delete user "${userName}" with ${passkeys}? This action cannot be undone.`,
|
||||
action: async () => {
|
||||
await performUserDeletion(userUuid, userName, orgUuid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function performUserDeletion(userUuid, userName, orgUuid) {
|
||||
try {
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
||||
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
|
||||
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
|
||||
await loadOrgs()
|
||||
window.location.hash = `#org/${orgUuid}`
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to delete user', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function moveUserToRole(user, targetRoleUuid) {
|
||||
if (user.role_uuid === targetRoleUuid) return
|
||||
try {
|
||||
await apiJson(`/auth/api/admin/users/${user.uuid}/role`, {
|
||||
method: 'PATCH',
|
||||
body: { role: targetRoleDisplayName }
|
||||
body: { role_uuid: targetRoleUuid }
|
||||
})
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
@@ -268,7 +300,7 @@ function onRoleDrop(e, org, role) {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
||||
if (data.org !== org.uuid) return // only within same org
|
||||
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
|
||||
if (user) moveUserToRole(org, user, role.display_name)
|
||||
if (user) moveUserToRole(user, role.uuid)
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -279,7 +311,7 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
|
||||
|
||||
function deleteRole(role) {
|
||||
// UI only allows deleting empty roles, so no confirmation needed
|
||||
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
||||
loadOrgs()
|
||||
@@ -299,7 +331,7 @@ async function toggleRolePermission(role, pid, checked) {
|
||||
|
||||
try {
|
||||
const method = checked ? 'POST' : 'DELETE'
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}/permissions/${pid}`, {
|
||||
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
|
||||
method
|
||||
})
|
||||
await loadOrgs()
|
||||
@@ -406,7 +438,7 @@ const breadcrumbEntries = computed(() => {
|
||||
watch(selectedUser, async (u) => {
|
||||
if (!u) { userDetail.value = null; return }
|
||||
try {
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org}/users/${u.uuid}`)
|
||||
userDetail.value = await apiJson(`/auth/api/admin/users/${u.uuid}`)
|
||||
} catch (e) {
|
||||
userDetail.value = { error: e.message }
|
||||
}
|
||||
@@ -542,7 +574,7 @@ async function refreshUserDetail() {
|
||||
await loadOrgs()
|
||||
if (selectedUser.value) {
|
||||
try {
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org}/users/${selectedUser.value.uuid}`)
|
||||
userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
||||
}
|
||||
}
|
||||
@@ -604,7 +636,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
@@ -632,7 +664,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${user.org}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
|
||||
apiJson(`/auth/api/admin/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||
onUserNameSaved()
|
||||
@@ -771,6 +803,7 @@ async function submitDialog() {
|
||||
@edit-user-name="editUserName"
|
||||
@close-reg-modal="showRegModal = false"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
@delete-user="deleteUser(selectedUser, userDetail)"
|
||||
/>
|
||||
<AdminOrgDetail
|
||||
v-else-if="selectedOrg"
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin</title>
|
||||
<title>Admin</title>
|
||||
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||
<link rel="stylesheet" href="/src/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="admin-app"></div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '@/assets/style.css'
|
||||
import { initThemeFromCache } from '@/utils/theme'
|
||||
initThemeFromCache()
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Auth Profile</title>
|
||||
<script>(localStorage.getItem('paskia-theme')==='dark'||localStorage.getItem('paskia-theme')!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')</script>
|
||||
<link rel="stylesheet" href="/src/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '@/assets/style.css'
|
||||
import { initThemeFromCache } from '@/utils/theme'
|
||||
initThemeFromCache()
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script>{let t=localStorage.getItem('paskia-theme');if(t!=='light'&&t!=='dark')t=new URLSearchParams(location.hash.slice(1)).get('theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')}</script>
|
||||
<link rel="stylesheet" href="/src/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import './theme.js'
|
||||
import { createApp } from 'vue'
|
||||
import RestrictedApi from './RestrictedApi.vue'
|
||||
import '@/assets/style.css'
|
||||
import { initKeyboardNavigation } from '@/utils/keynav'
|
||||
|
||||
createApp(RestrictedApi).mount('#app')
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Early theme for restricted app - first URL param wins, then localStorage
|
||||
import { applyTheme, getCachedTheme } from '@/utils/theme.js'
|
||||
|
||||
function getTheme() {
|
||||
const params = new URLSearchParams(location.hash.slice(1))
|
||||
return params.get('theme') || getCachedTheme() || ''
|
||||
}
|
||||
|
||||
// Apply theme class to document root
|
||||
applyTheme(getTheme())
|
||||
addEventListener('hashchange', () => applyTheme(getTheme()))
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -339,6 +339,7 @@ defineExpose({ focusFirstElement })
|
||||
<strong class="role-name" :title="r.uuid">
|
||||
<span>{{ r.display_name }}</span>
|
||||
<button @click="$emit('updateRole', r)" class="icon-btn" aria-label="Edit role" title="Edit role">✏️</button>
|
||||
<button v-if="r.users.length === 0" @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete role" title="Delete role">❌</button>
|
||||
</strong>
|
||||
<div class="role-actions">
|
||||
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||
@@ -369,9 +370,8 @@ defineExpose({ focusFirstElement })
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div v-else class="empty-role" @keydown="e => handleEmptyRoleKeydown(e, roleIndex)">
|
||||
<div v-else class="empty-role">
|
||||
<p class="empty-text muted">No members</p>
|
||||
<button @click="$emit('deleteRole', r)" class="icon-btn delete-icon" aria-label="Delete empty role" title="Delete role">❌</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ const props = defineProps({
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut'])
|
||||
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const terminatingSessions = ref({})
|
||||
@@ -45,7 +45,7 @@ function handleEditName() {
|
||||
|
||||
async function handleDelete(credential) {
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
} else {
|
||||
@@ -61,7 +61,7 @@ async function handleTerminateSession(session) {
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
if (data.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
@@ -83,6 +83,10 @@ async function handleTerminateSession(session) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser() {
|
||||
emit('deleteUser')
|
||||
}
|
||||
|
||||
// Handle user info section keynav
|
||||
function handleUserInfoKeydown(event) {
|
||||
if (hasActiveModal.value || props.navigationDisabled) return
|
||||
@@ -183,24 +187,27 @@ defineExpose({ focusFirstElement })
|
||||
:loading="loading"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role"
|
||||
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/display-name`"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@edit-name="handleEditName"
|
||||
/>
|
||||
>
|
||||
<div class="admin-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
@click="$emit('generateUserRegistrationLink', selectedUser)"
|
||||
:disabled="loading"
|
||||
>{{ userDetail?.credentials?.length ? 'Recovery Link' : 'Registration Link' }}</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
@click="handleDeleteUser"
|
||||
:disabled="loading"
|
||||
title="Delete this user"
|
||||
>Delete User</button>
|
||||
</div>
|
||||
</UserBasicInfo>
|
||||
</div>
|
||||
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||
<template v-if="userDetail && !userDetail.error">
|
||||
<div class="registration-actions" ref="regActionsRef" @keydown="handleRegActionsKeydown">
|
||||
<button
|
||||
class="btn-secondary reg-token-btn"
|
||||
@click="$emit('generateUserRegistrationLink', selectedUser)"
|
||||
:disabled="loading"
|
||||
>Generate Registration Token</button>
|
||||
<p class="matrix-hint muted">
|
||||
Generate a one-time registration link so this user can register or add another passkey.
|
||||
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
|
||||
</p>
|
||||
</div>
|
||||
<section class="section-block" data-section="registered-passkeys">
|
||||
<div class="section-header">
|
||||
<h2>Registered Passkeys</h2>
|
||||
@@ -238,7 +245,7 @@ defineExpose({ focusFirstElement })
|
||||
</div>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/create-link`"
|
||||
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
@@ -248,13 +255,11 @@ defineExpose({ focusFirstElement })
|
||||
|
||||
<style scoped>
|
||||
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
||||
.admin-actions { display: flex; gap: 0.5rem; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
||||
.ancillary-actions { margin-top: -0.5rem; }
|
||||
.reg-token-btn { align-self: flex-start; }
|
||||
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
|
||||
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
|
||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.error { color: var(--color-danger-text); }
|
||||
.small { font-size: 0.9rem; }
|
||||
.muted { color: var(--color-text-muted); }
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
|
||||
@property --hue {
|
||||
syntax: '<angle>';
|
||||
inherits: true;
|
||||
initial-value: 0.72turn;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--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);
|
||||
--color-canvas: white;
|
||||
--color-surface: oklch(0.97 0.005 var(--hue));
|
||||
--color-surface-subtle: oklch(0.94 0.01 var(--hue));
|
||||
--color-dialog: white;
|
||||
--color-border: oklch(0.8 0.02 var(--hue));
|
||||
--color-border-strong: oklch(0.55 0.15 var(--hue));
|
||||
--color-heading: oklch(0.3 0.05 var(--hue));
|
||||
--color-text: oklch(0.25 0.02 var(--hue));
|
||||
--color-text-muted: oklch(0.5 0.02 var(--hue));
|
||||
--color-link: oklch(0.5 0.18 var(--hue));
|
||||
--color-link-hover: oklch(0.45 0.2 var(--hue));
|
||||
--color-accent: oklch(0.55 0.2 var(--hue));
|
||||
--color-accent-strong: oklch(0.45 0.2 var(--hue));
|
||||
--color-accent-contrast: white;
|
||||
--color-secondary: oklch(0.55 0.05 var(--hue));
|
||||
--color-secondary-strong: oklch(0.45 0.05 var(--hue));
|
||||
--color-success-text: oklch(0.4 0.15 0.4turn);
|
||||
--color-success-bg: oklch(0.95 0.03 0.4turn);
|
||||
--color-error-text: oklch(0.45 0.2 0.07turn);
|
||||
--color-error-bg: oklch(0.95 0.03 0.07turn);
|
||||
--color-info-text: oklch(0.45 0.15 var(--hue));
|
||||
--color-info-bg: oklch(0.95 0.02 var(--hue));
|
||||
--color-danger: oklch(0.55 0.22 0.07turn);
|
||||
--shadow-soft: 0 0 .2rem black;
|
||||
--radius-none: 0;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
@@ -39,30 +48,31 @@
|
||||
--focus-ring: 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--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;
|
||||
}
|
||||
:root.dark {
|
||||
--color-canvas: oklch(0.15 0.03 var(--hue));
|
||||
--color-surface: oklch(0.18 0.03 var(--hue));
|
||||
--color-surface-subtle: oklch(0.22 0.03 var(--hue));
|
||||
--color-dialog: oklch(0.22 0.03 var(--hue));
|
||||
--color-border: oklch(0.3 0.03 var(--hue));
|
||||
--color-border-strong: oklch(0.4 0.04 var(--hue));
|
||||
--color-heading: white;
|
||||
--color-text: oklch(0.9 0.01 var(--hue));
|
||||
--color-text-muted: oklch(0.7 0.02 var(--hue));
|
||||
--color-link: oklch(0.7 0.15 var(--hue));
|
||||
--color-link-hover: oklch(0.8 0.12 var(--hue));
|
||||
--color-accent: oklch(0.7 0.15 var(--hue));
|
||||
--color-accent-strong: oklch(0.6 0.18 var(--hue));
|
||||
--color-accent-contrast: oklch(0.12 0.03 var(--hue));
|
||||
--color-secondary: oklch(0.6 0.05 var(--hue));
|
||||
--color-secondary-strong: oklch(0.5 0.05 var(--hue));
|
||||
--color-success-text: oklch(0.75 0.15 0.4turn);
|
||||
--color-success-bg: oklch(0.3 0.08 0.4turn);
|
||||
--color-error-text: oklch(0.8 0.12 0.07turn);
|
||||
--color-error-bg: oklch(0.3 0.08 0.07turn);
|
||||
--color-info-text: oklch(0.8 0.1 var(--hue));
|
||||
--color-info-bg: oklch(0.3 0.05 var(--hue));
|
||||
--color-danger: oklch(0.7 0.18 0.07turn);
|
||||
--shadow-soft: 0 0 0 black;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -78,7 +88,6 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
color-scheme: light dark;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
height: 100%;
|
||||
@@ -132,6 +141,7 @@ a:focus-visible {
|
||||
}
|
||||
|
||||
.view-root {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -229,6 +239,7 @@ button {
|
||||
gap: 0.4rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: box-shadow var(--transition-base), background var(--transition-base), border-color var(--transition-base);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -237,43 +248,41 @@ button:disabled {
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-accent);
|
||||
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-accent);
|
||||
color: var(--color-accent-contrast);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled),
|
||||
.btn-primary:focus-visible {
|
||||
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-accent-strong);
|
||||
border-color: var(--color-accent-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-strong);
|
||||
border-color: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger);
|
||||
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-secondary);
|
||||
color: var(--color-accent-contrast);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
filter: brightness(0.92);
|
||||
.btn-secondary:hover:not(:disabled),
|
||||
.btn-secondary:focus-visible {
|
||||
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-secondary-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
/* Focus-visible outlines for buttons */
|
||||
.btn-primary:focus-visible,
|
||||
.btn-secondary:focus-visible,
|
||||
.btn-danger {
|
||||
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-danger);
|
||||
color: var(--color-accent-contrast);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled),
|
||||
.btn-danger:focus-visible {
|
||||
outline: 1px solid var(-webkit-focus-ring-color);
|
||||
background: linear-gradient(to bottom, oklch(1 0 0 / 0.15), transparent 60%) var(--color-danger);
|
||||
filter: brightness(0.92);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
@@ -407,7 +416,7 @@ th {
|
||||
|
||||
.device-dialog,
|
||||
.modal {
|
||||
background: var(--color-surface);
|
||||
background: var(--color-dialog);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
width: min(520px, 100%);
|
||||
@@ -429,7 +438,7 @@ th {
|
||||
|
||||
.qr-code {
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
background: white;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
@@ -567,7 +576,7 @@ th {
|
||||
}
|
||||
|
||||
.btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.btn-card-delete:hover:not(:disabled) { background: #fee; }
|
||||
.btn-card-delete:hover:not(:disabled) { filter: brightness(0.85); }
|
||||
.btn-card-delete:disabled { filter: opacity(0.4); cursor: not-allowed; }
|
||||
|
||||
|
||||
@@ -692,8 +701,8 @@ th {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
|
||||
backdrop-filter: blur(.2rem) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -711,7 +720,7 @@ th {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 2rem;
|
||||
background: var(--color-surface);
|
||||
background: var(--color-dialog);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 20px 60px #1e293b;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<section class="view-root" data-view="profile">
|
||||
<div class="theme-toggle">
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
<header class="view-header">
|
||||
<h1>User Profile</h1>
|
||||
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||
@@ -117,6 +120,7 @@
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
@@ -352,8 +356,6 @@ 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); }
|
||||
</style>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
|
||||
<div class="reg-header-row">
|
||||
<h2 id="regTitle" class="reg-title">
|
||||
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
|
||||
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
|
||||
</h2>
|
||||
<button class="icon-btn" @click="$emit('close')" aria-label="Close" tabindex="-1">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="device-link-section">
|
||||
<p class="reg-help">
|
||||
Scan this QR code on the new device, or copy the link and open it there.
|
||||
{{ helpText }}
|
||||
</p>
|
||||
|
||||
<QRCodeDisplay
|
||||
@@ -33,7 +33,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||
import { apiJson } from 'paskia'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
@@ -51,16 +51,31 @@ const authStore = useAuthStore()
|
||||
const dialog = ref(null)
|
||||
const linkUrl = ref(null)
|
||||
const expiresAt = ref(null)
|
||||
const tokenType = ref(null)
|
||||
const actionsRow = ref(null)
|
||||
// Store the element that had focus before modal opened
|
||||
const previouslyFocusedElement = ref(null)
|
||||
|
||||
// Determine if this is an admin action for another user
|
||||
const isAdminAction = computed(() => !!props.userName)
|
||||
|
||||
// Compute the help text based on token type and context
|
||||
const helpText = computed(() => {
|
||||
if (!isAdminAction.value) {
|
||||
// User adding their own device
|
||||
return 'Scan this QR code on the new device, or copy the link and open it there.'
|
||||
}
|
||||
// Admin action for another user
|
||||
return `Send this link to ${props.userName}, or have them scan the QR code.`
|
||||
})
|
||||
|
||||
async function generateLink() {
|
||||
try {
|
||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||
if (data.url) {
|
||||
linkUrl.value = data.url
|
||||
expiresAt.value = data.expires ? new Date(data.expires) : null
|
||||
tokenType.value = data.token_type || null
|
||||
|
||||
// Show the dialog as modal
|
||||
await nextTick()
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<!-- Device info display (shown when 3 words match a request) -->
|
||||
<div v-else-if="deviceInfo" class="device-info">
|
||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
|
||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
|
||||
|
||||
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
@keydown="handleItemKeydown($event, session)"
|
||||
>
|
||||
<div class="item-top">
|
||||
<h4 class="item-title">{{ session.user_agent }}</h4>
|
||||
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="theme-selector" @click.stop>
|
||||
<button v-for="t in themes" :key="t.value" class="theme-icon" :class="{ hidden: isHidden(t.value) }"
|
||||
:style="{ top: getPos(t.value).y + 'px', left: getPos(t.value).x + 'px' }" :title="t.title"
|
||||
@click="handleClick(t.value)">{{ t.icon }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { apiJson } from 'paskia'
|
||||
import { updateThemeFromSession, getCachedTheme } from '@/utils/theme'
|
||||
|
||||
const open = ref(false), closing = ref(false), closingValue = ref(null), selected = ref(getCachedTheme())
|
||||
const themes = [{ value: '', icon: '🌓', title: 'Auto' }, { value: 'light', icon: '☀️', title: 'Light' }, { value: 'dark', icon: '🌙', title: 'Dark' }]
|
||||
const center = { x: 16, y: 16 }
|
||||
const expanded = { '': { x: 16, y: 0 }, light: { x: 0, y: 28 }, dark: { x: 32, y: 28 } }
|
||||
|
||||
const getPos = v => closing.value ? (v === closingValue.value ? center : expanded[v]) : open.value ? expanded[v] : (v === selected.value ? center : expanded[v])
|
||||
const isHidden = v => closing.value ? v !== closingValue.value : !open.value && v !== selected.value
|
||||
|
||||
function close(v) {
|
||||
closingValue.value = v
|
||||
closing.value = true
|
||||
setTimeout(() => { open.value = closing.value = false; closingValue.value = null }, 200)
|
||||
}
|
||||
|
||||
function handleClick(v) {
|
||||
if (!open.value) { open.value = true; return }
|
||||
close(v)
|
||||
setTimeout(() => {
|
||||
selected.value = v
|
||||
updateThemeFromSession({ user: { theme: v } }, true)
|
||||
apiJson('/auth/api/user/theme', { method: 'PATCH', body: { theme: v } }).catch(() => {})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function onOutside(e) { if (open.value && !closing.value && !e.target.closest('.theme-selector')) close(selected.value) }
|
||||
onMounted(() => document.addEventListener('click', onOutside))
|
||||
onUnmounted(() => document.removeEventListener('click', onOutside))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-selector { position: relative; width: 2rem; height: 2rem; }
|
||||
.theme-icon { position: absolute; transform: translate(-50%, -50%); background: none; border: none; font-size: 1.25rem; cursor: pointer; padding: 0.25rem; transition: top 0.2s, left 0.2s, opacity 0.15s; }
|
||||
.theme-icon:hover, .theme-icon:focus-visible { transform: translate(-50%, -50%) scale(1.15); }
|
||||
.theme-icon.hidden { opacity: 0; pointer-events: none; }
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Theme override utilities - shared across apps
|
||||
// User preference or URL hash can force light/dark mode
|
||||
|
||||
const TRANSITION_ID = 'theme-transition'
|
||||
const STORAGE_KEY = 'paskia-theme'
|
||||
|
||||
/** Apply theme by setting class on documentElement */
|
||||
export function applyTheme(theme, element = document.documentElement, 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)
|
||||
}
|
||||
// If no explicit theme, check system preference
|
||||
const isDark = theme === 'dark' || (theme !== 'light' && matchMedia('(prefers-color-scheme:dark)').matches)
|
||||
element.classList.toggle('dark', isDark)
|
||||
}
|
||||
|
||||
/** 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, document.documentElement, animate)
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Paskia
|
||||
|
||||

|
||||

|
||||
|
||||
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
|
||||
|
||||
|
||||
+13
-4
@@ -1,7 +1,15 @@
|
||||
{
|
||||
"name": "paskia",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Paskia authentication utilities for JavaScript",
|
||||
"author": "Leo Vasanko",
|
||||
"license": "Unlicense",
|
||||
"homepage": "https://git.zi.fi/LeoVasanko/paskia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/LeoVasanko/paskia",
|
||||
"directory": "paskia-js"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/paskia.js",
|
||||
"types": "./dist/paskia.d.ts",
|
||||
@@ -26,7 +34,8 @@
|
||||
"keywords": [
|
||||
"auth",
|
||||
"authentication",
|
||||
"paskia"
|
||||
],
|
||||
"license": "Unlicense"
|
||||
"paskia",
|
||||
"passkey",
|
||||
"webauthn"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ body::before {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
body.paskia-backdrop::before {
|
||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
||||
backdrop-filter: blur(.2rem) brightness(0.5);
|
||||
backdrop-filter: blur(.2rem) brightness(0.7);
|
||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.7);
|
||||
visibility: visible;
|
||||
}
|
||||
body.paskia-backdrop {
|
||||
|
||||
+10
-11
@@ -10,23 +10,23 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from paskia import authsession, db, globals
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import hostutil
|
||||
|
||||
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,16 +72,15 @@ 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()
|
||||
db.create_reset_token(
|
||||
token = db.create_reset_token(
|
||||
user_uuid=admin_user.uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type="admin registration",
|
||||
)
|
||||
_log_reset_link("⚠️ Admin user has no credentials!", token)
|
||||
_log_reset_link(token)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -63,7 +63,7 @@ from paskia.db.operations import (
|
||||
update_session,
|
||||
update_user_display_name,
|
||||
update_user_role,
|
||||
update_user_role_in_organization,
|
||||
update_user_theme,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
@@ -146,5 +146,5 @@ __all__ = [
|
||||
"update_session",
|
||||
"update_user_display_name",
|
||||
"update_user_role",
|
||||
"update_user_role_in_organization",
|
||||
"update_user_theme",
|
||||
]
|
||||
|
||||
@@ -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():
|
||||
|
||||
+4
-4
@@ -67,7 +67,7 @@ def create_change_record(
|
||||
|
||||
|
||||
# Actions that are allowed to create a new database file
|
||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap", "migrate:sql"})
|
||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
||||
|
||||
|
||||
async def flush_changes(
|
||||
@@ -91,7 +91,7 @@ async def flush_changes(
|
||||
if first_action not in _BOOTSTRAP_ACTIONS:
|
||||
_logger.error(
|
||||
"Refusing to create database file with action '%s' - "
|
||||
"only bootstrap or migrate can create a new database",
|
||||
"only bootstrap can create a new database",
|
||||
first_action,
|
||||
)
|
||||
pending_changes.clear()
|
||||
@@ -233,8 +233,8 @@ class JsonlStore:
|
||||
# Check for out-of-transaction modifications
|
||||
current_state = msgspec.to_builtins(self.db)
|
||||
if current_state != self._previous_builtins:
|
||||
# Allow bootstrap/migrate to create a new database from empty state
|
||||
is_bootstrap = action in _BOOTSTRAP_ACTIONS or action.startswith("migrate:")
|
||||
# Allow bootstrap to create a new database from empty state
|
||||
is_bootstrap = action in _BOOTSTRAP_ACTIONS
|
||||
if is_bootstrap and not self._previous_builtins:
|
||||
pass # Expected: creating database from scratch
|
||||
else:
|
||||
|
||||
@@ -19,7 +19,7 @@ migrations = sorted(
|
||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
||||
)
|
||||
|
||||
DBVER = len(migrations) # Used by bootstrap and migrate:sql to set initial version
|
||||
DBVER = len(migrations) # Used by bootstrap to set initial version
|
||||
|
||||
|
||||
async def apply_all_migrations(
|
||||
|
||||
+40
-44
@@ -31,7 +31,6 @@ from paskia.db.structs import (
|
||||
SessionContext,
|
||||
User,
|
||||
)
|
||||
from paskia.util.passphrase import generate as generate_passphrase
|
||||
from paskia.util.passphrase import is_well_formed as _is_passphrase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -68,12 +67,12 @@ def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
|
||||
Raises ValueError if user not found.
|
||||
|
||||
Call sites:
|
||||
- update_user_role_in_organization: org only
|
||||
- admin_create_user_registration_link: org only
|
||||
- admin_get_user_detail: org and role
|
||||
- admin_update_user_display_name: org only
|
||||
- admin_delete_user_credential: org only
|
||||
- admin_delete_user_session: org only
|
||||
- admin_update_user_role: org only
|
||||
"""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
@@ -352,6 +351,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,
|
||||
@@ -367,29 +383,6 @@ def update_user_role(
|
||||
_db.users[uuid].role_uuid = role_uuid
|
||||
|
||||
|
||||
def update_user_role_in_organization(
|
||||
user_uuid: UUID,
|
||||
role_name: str,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update user's role by role name within their current organization."""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
user = _db.users[user_uuid]
|
||||
org = user.org
|
||||
# Find role by name in the same org
|
||||
new_role_uuid = None
|
||||
for r in org.roles:
|
||||
if r.display_name == role_name:
|
||||
new_role_uuid = r.uuid
|
||||
break
|
||||
if new_role_uuid is None:
|
||||
raise ValueError(f"Role '{role_name}' not found in organization")
|
||||
with _db.transaction("admin:update_user_role", ctx):
|
||||
_db.users[user_uuid].role_uuid = new_role_uuid
|
||||
|
||||
|
||||
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete user and their credentials/sessions."""
|
||||
if uuid not in _db.users:
|
||||
@@ -402,9 +395,10 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
# Delete sessions
|
||||
for sess in user.sessions:
|
||||
del _db.sessions[sess.key]
|
||||
# Delete reset tokens
|
||||
for token in user.reset_tokens:
|
||||
del _db.reset_tokens[token.key]
|
||||
# Delete reset tokens (iterate over dict items to get correct keys)
|
||||
for key, token in list(_db.reset_tokens.items()):
|
||||
if token.user_uuid == uuid:
|
||||
del _db.reset_tokens[key]
|
||||
del _db.users[uuid]
|
||||
|
||||
|
||||
@@ -550,31 +544,34 @@ def delete_sessions_for_user(
|
||||
|
||||
|
||||
def create_reset_token(
|
||||
passphrase: str,
|
||||
user_uuid: UUID,
|
||||
expiry: datetime,
|
||||
token_type: str,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
user: str | None = None,
|
||||
) -> None:
|
||||
"""Create a reset token from a passphrase.
|
||||
) -> str:
|
||||
"""Create a reset token and return the passphrase.
|
||||
|
||||
The acting user should be logged via ctx.
|
||||
For self-service (user creating own recovery link), pass user's ctx.
|
||||
For admin operations, pass admin's ctx.
|
||||
For system operations (bootstrap), pass neither to log no user.
|
||||
For API operations where ctx is not available but user is known, pass user.
|
||||
|
||||
Returns:
|
||||
The passphrase to give to the user.
|
||||
"""
|
||||
key = _reset_key(passphrase)
|
||||
if key in _db.reset_tokens:
|
||||
raise ValueError("Reset token already exists")
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
token, passphrase = ResetToken.create(
|
||||
user=user_uuid, expiry=expiry, token_type=token_type
|
||||
)
|
||||
if token.key in _db.reset_tokens:
|
||||
raise ValueError("Reset token already exists")
|
||||
with _db.transaction("create_reset_token", ctx, user=user):
|
||||
_db.reset_tokens[key] = ResetToken(
|
||||
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
||||
)
|
||||
_db.reset_tokens[token.key] = token
|
||||
return passphrase
|
||||
|
||||
|
||||
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -737,7 +734,7 @@ def bootstrap(
|
||||
- Admin user with Administration role
|
||||
- Reset token for admin registration
|
||||
|
||||
This is the only way to create a new database file (besides migrate).
|
||||
This is the only way to create a new database file.
|
||||
All data is created atomically - if any step fails, nothing is written.
|
||||
|
||||
Args:
|
||||
@@ -764,14 +761,11 @@ def bootstrap(
|
||||
role_uuid = uuid7.create()
|
||||
user_uuid = uuid7.create()
|
||||
|
||||
# Generate reset token components
|
||||
if reset_passphrase is None:
|
||||
reset_passphrase = generate_passphrase()
|
||||
# Set reset token expiry (passphrase generated by ResetToken.create)
|
||||
if reset_expiry is None:
|
||||
from paskia.authsession import reset_expires # noqa: PLC0415
|
||||
|
||||
reset_expiry = reset_expires()
|
||||
reset_key = _reset_key(reset_passphrase)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -820,10 +814,12 @@ def bootstrap(
|
||||
_db.users[user_uuid] = admin_user
|
||||
|
||||
# Create reset token
|
||||
_db.reset_tokens[reset_key] = ResetToken(
|
||||
user_uuid=user_uuid,
|
||||
reset_token, reset_passphrase = ResetToken.create(
|
||||
user=user_uuid,
|
||||
expiry=reset_expiry,
|
||||
token_type="admin bootstrap",
|
||||
passphrase=reset_passphrase,
|
||||
)
|
||||
_db.reset_tokens[reset_token.key] = reset_token
|
||||
|
||||
return reset_passphrase
|
||||
|
||||
+43
-9
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
@@ -9,6 +10,7 @@ import uuid7
|
||||
|
||||
from paskia import db
|
||||
from paskia.util.hostutil import normalize_host
|
||||
from paskia.util.passphrase import generate as generate_passphrase
|
||||
|
||||
# Sentinel for uuid fields before they are set by create() or DB post init
|
||||
_UUID_UNSET = UUID(int=0)
|
||||
@@ -147,10 +149,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 +162,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"):
|
||||
@@ -352,6 +355,38 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
"""Get the User object for this reset token."""
|
||||
return db.data().users[self.user_uuid]
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
user: UUID | User,
|
||||
expiry: datetime,
|
||||
token_type: str,
|
||||
passphrase: str | None = None,
|
||||
) -> tuple[ResetToken, str]:
|
||||
"""Create a new ResetToken with auto-generated or provided passphrase.
|
||||
|
||||
Args:
|
||||
user: User UUID or User object
|
||||
expiry: Token expiration datetime
|
||||
token_type: Type of token (e.g., "device addition", "account recovery")
|
||||
passphrase: Optional passphrase to use (auto-generated if not provided)
|
||||
|
||||
Returns:
|
||||
Tuple of (token, passphrase) where passphrase is the human-readable
|
||||
code to give to the user.
|
||||
"""
|
||||
if passphrase is None:
|
||||
passphrase = generate_passphrase()
|
||||
key = hashlib.sha512(passphrase.encode()).digest()[:9]
|
||||
user_uuid = user if isinstance(user, UUID) else user.uuid
|
||||
token = cls(
|
||||
user_uuid=user_uuid,
|
||||
expiry=expiry,
|
||||
token_type=token_type,
|
||||
)
|
||||
token.key = key
|
||||
return token, passphrase
|
||||
|
||||
|
||||
class SessionContext(msgspec.Struct):
|
||||
session: Session
|
||||
@@ -418,8 +453,11 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
# Normalize host for comparison (stored hosts are already normalized)
|
||||
normalized_input = normalize_host(host)
|
||||
|
||||
# Validate host matches (sessions are always created with a host)
|
||||
if s.host != host:
|
||||
if s.host != normalized_input:
|
||||
# Session bound to different host
|
||||
return None
|
||||
|
||||
@@ -434,10 +472,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
# Effective permissions: role's permissions that the org can grant
|
||||
# Also filter by domain if host is provided
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
normalized_host = normalize_host(host)
|
||||
host_without_port = (
|
||||
normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
)
|
||||
|
||||
effective_perms = []
|
||||
for perm_uuid in role.permission_set:
|
||||
@@ -447,8 +481,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
p = self.permissions[perm_uuid]
|
||||
except KeyError:
|
||||
continue
|
||||
# Check domain restriction
|
||||
if p.domain is not None and p.domain != host_without_port:
|
||||
# Check domain restriction (normalized_input already has port stripped)
|
||||
if p.domain is not None and p.domain != normalized_input:
|
||||
continue
|
||||
effective_perms.append(p)
|
||||
|
||||
|
||||
+21
-13
@@ -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 {}
|
||||
|
||||
+90
-84
@@ -17,7 +17,6 @@ from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import (
|
||||
hostutil,
|
||||
passphrase,
|
||||
permutil,
|
||||
querysafe,
|
||||
vitedev,
|
||||
@@ -103,6 +102,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
"uuid": u.uuid,
|
||||
"display_name": u.display_name,
|
||||
"role": role_name,
|
||||
"role_uuid": u.role_uuid,
|
||||
"visits": u.visits,
|
||||
"last_seen": u.last_seen,
|
||||
}
|
||||
@@ -281,28 +281,27 @@ async def admin_create_role(
|
||||
return {"uuid": str(role.uuid)}
|
||||
|
||||
|
||||
@app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
@app.patch("/roles/{role_uuid}")
|
||||
async def admin_update_role_name(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update role display name only."""
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
display_name = payload.get("display_name")
|
||||
if not display_name:
|
||||
@@ -312,68 +311,64 @@ async def admin_update_role_name(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
|
||||
@app.post("/roles/{role_uuid}/permissions/{permission_uuid}")
|
||||
async def admin_add_role_permission(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
permission_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Add a permission to a role (intent-based API)."""
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Verify permission exists and org can grant it
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if not perm:
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
if org_uuid not in perm.orgs:
|
||||
if role.org_uuid not in perm.orgs:
|
||||
raise ValueError("Permission not grantable by organization")
|
||||
|
||||
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
|
||||
@app.delete("/roles/{role_uuid}/permissions/{permission_uuid}")
|
||||
async def admin_remove_role_permission(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
permission_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Remove a permission from a role (intent-based API)."""
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
|
||||
if ctx.org.uuid == role.org_uuid and ctx.role.uuid == role_uuid:
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
# Check if removing this permission would leave no admin access
|
||||
remaining_perms = role.permission_set - {permission_uuid}
|
||||
@@ -390,13 +385,15 @@ async def admin_remove_role_permission(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
@app.delete("/roles/{role_uuid}")
|
||||
async def admin_delete_role(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role:
|
||||
raise HTTPException(status_code=404, detail="Role not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
@@ -404,13 +401,10 @@ async def admin_delete_role(
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from deleting their own role
|
||||
if ctx.role.uuid == role_uuid:
|
||||
@@ -460,60 +454,58 @@ async def admin_create_user(
|
||||
return {"uuid": str(user.uuid)}
|
||||
|
||||
|
||||
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||
@app.patch("/users/{user_uuid}/role")
|
||||
async def admin_update_user_role(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user_org, _current_role = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
new_role = payload.get("role")
|
||||
if not new_role:
|
||||
raise ValueError("role is required")
|
||||
role_uuid_str = payload.get("role_uuid")
|
||||
if not role_uuid_str:
|
||||
raise ValueError("role_uuid is required")
|
||||
try:
|
||||
user_org, _current_role = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise ValueError("User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise ValueError("User does not belong to this organization")
|
||||
roles = user_org.roles
|
||||
if not any(r.display_name == new_role for r in roles):
|
||||
new_role_uuid = UUID(role_uuid_str)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("Invalid role UUID")
|
||||
new_role = db.data().roles.get(new_role_uuid)
|
||||
if not new_role or new_role.org_uuid != user_org.uuid:
|
||||
raise ValueError("Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
if ctx.user.uuid == user_uuid:
|
||||
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
||||
if new_role_obj: # pragma: no branch - always true, role validated above
|
||||
# Check if any permission in the new role is an admin permission
|
||||
has_admin_access = False
|
||||
for perm_uuid in new_role_obj.permissions:
|
||||
perm = db.data().permissions.get(perm_uuid)
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
has_admin_access = True
|
||||
break
|
||||
if not has_admin_access:
|
||||
raise ValueError(
|
||||
"Cannot change your own role to one without admin permissions"
|
||||
)
|
||||
# Check if any permission in the new role is an admin permission
|
||||
has_admin_access = False
|
||||
for perm_uuid in new_role.permissions:
|
||||
perm = db.data().permissions.get(perm_uuid)
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
has_admin_access = True
|
||||
break
|
||||
if not has_admin_access:
|
||||
raise ValueError(
|
||||
"Cannot change your own role to one without admin permissions"
|
||||
)
|
||||
|
||||
db.update_user_role_in_organization(user_uuid, new_role, ctx=ctx)
|
||||
db.update_user_role(user_uuid, new_role_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
|
||||
@app.post("/users/{user_uuid}/create-link")
|
||||
async def admin_create_user_registration_link(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
@@ -522,8 +514,6 @@ async def admin_create_user_registration_link(
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
@@ -531,7 +521,7 @@ async def admin_create_user_registration_link(
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
@@ -540,11 +530,9 @@ async def admin_create_user_registration_link(
|
||||
has_credentials = db.get_user_credential_ids(user_uuid)
|
||||
token_type = "user registration" if not has_credentials else "account recovery"
|
||||
|
||||
token = passphrase.generate()
|
||||
expiry = reset_expires()
|
||||
db.create_reset_token(
|
||||
token = db.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type=token_type,
|
||||
ctx=ctx,
|
||||
@@ -553,12 +541,12 @@ async def admin_create_user_registration_link(
|
||||
return {
|
||||
"url": url,
|
||||
"expires": format_datetime(expiry),
|
||||
"token_type": token_type,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
|
||||
@app.get("/users/{user_uuid}")
|
||||
async def admin_get_user_detail(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
@@ -567,15 +555,13 @@ async def admin_get_user_detail(
|
||||
user_org, role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
@@ -615,9 +601,8 @@ async def admin_get_user_detail(
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
||||
@app.patch("/users/{user_uuid}/display-name")
|
||||
async def admin_update_user_display_name(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
@@ -627,15 +612,13 @@ async def admin_update_user_display_name(
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
@@ -648,9 +631,37 @@ async def admin_update_user_display_name(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
|
||||
@app.delete("/users/{user_uuid}")
|
||||
async def admin_delete_user(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Delete a user and all their credentials/sessions."""
|
||||
try:
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
# Prevent admin from deleting themselves
|
||||
if ctx.user.uuid == user_uuid:
|
||||
raise ValueError("Cannot delete your own account")
|
||||
db.delete_user(user_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/users/{user_uuid}/credentials/{credential_uuid}")
|
||||
async def admin_delete_user_credential(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
request: Request,
|
||||
@@ -660,8 +671,6 @@ async def admin_delete_user_credential(
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
@@ -669,7 +678,7 @@ async def admin_delete_user_credential(
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
@@ -677,9 +686,8 @@ async def admin_delete_user_credential(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
|
||||
@app.delete("/users/{user_uuid}/sessions/{session_id}")
|
||||
async def admin_delete_user_session(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
session_id: str,
|
||||
request: Request,
|
||||
@@ -689,15 +697,13 @@ async def admin_delete_user_session(
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="User not found in organization")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from paskia import db
|
||||
from paskia.authsession import EXPIRES, expires, get_reset
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
||||
|
||||
@@ -91,7 +91,7 @@ async def validate_token(
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
db.update_session(
|
||||
auth,
|
||||
ip=request.client.host if request.client else "",
|
||||
ip=get_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent") or "",
|
||||
expiry=expires(),
|
||||
ctx=ctx,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,10 +38,33 @@ _AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
|
||||
|
||||
|
||||
def format_ipv6_network(ip: str) -> str:
|
||||
"""Format IPv6 address to show only network part (first 64 bits)."""
|
||||
"""Format IPv6 address to show only network part (first 64 bits).
|
||||
|
||||
Special addresses are returned as-is for clarity:
|
||||
- ::1 (loopback)
|
||||
- :: (unspecified)
|
||||
- ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
|
||||
- fe80:: (link-local, returned as-is since interface-specific)
|
||||
"""
|
||||
try:
|
||||
# Strip brackets that some proxies add around IPv6
|
||||
ip = ip.strip("[]")
|
||||
# Strip zone ID (e.g., fe80::1%eth0)
|
||||
if "%" in ip:
|
||||
ip = ip.split("%")[0]
|
||||
addr = IPv6Address(ip)
|
||||
# Get the integer representation and mask to first 64 bits
|
||||
|
||||
# Special cases - return as-is or with minimal processing
|
||||
if addr.is_loopback: # ::1
|
||||
return "::1"
|
||||
if addr.is_unspecified: # ::
|
||||
return "::"
|
||||
if addr.ipv4_mapped: # ::ffff:x.x.x.x
|
||||
return str(addr.ipv4_mapped)
|
||||
if addr.is_link_local: # fe80::/10 - interface-specific, keep full
|
||||
return str(addr)
|
||||
|
||||
# Regular addresses: truncate to /64 network prefix
|
||||
network_int = int(addr) >> 64
|
||||
# Format as IPv6 with trailing ::
|
||||
# Split into 4 groups of 16 bits
|
||||
@@ -61,7 +84,9 @@ def format_client_ip(ip: str) -> str:
|
||||
"""Format client IP, compressing IPv6 to network part only."""
|
||||
if not ip or ip == "-":
|
||||
return "-"
|
||||
if ":" in ip:
|
||||
# Strip brackets for detection (some proxies add them)
|
||||
stripped = ip.strip("[]")
|
||||
if ":" in stripped:
|
||||
return format_ipv6_network(ip)
|
||||
return ip
|
||||
|
||||
|
||||
@@ -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, "/")
|
||||
|
||||
@@ -20,7 +20,7 @@ from paskia.authsession import expires
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_and_login
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.util import passphrase, pow, useragent
|
||||
from paskia.util import pow, useragent
|
||||
|
||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -317,16 +317,13 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
|
||||
if request.action == "register":
|
||||
# For registration, create a reset token for device addition
|
||||
token_str = passphrase.generate()
|
||||
expiry = expires()
|
||||
db.create_reset_token(
|
||||
reset_token = db.create_reset_token(
|
||||
user_uuid=ctx.user.uuid,
|
||||
passphrase=token_str,
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
user=str(ctx.user.uuid),
|
||||
)
|
||||
reset_token = token_str
|
||||
|
||||
# Complete the remote auth request (notifies the waiting device)
|
||||
cred = db.data().credentials[ctx.session.credential_uuid]
|
||||
|
||||
@@ -15,7 +15,7 @@ from uuid import UUID
|
||||
|
||||
from paskia import authsession as _authsession
|
||||
from paskia import db
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import hostutil
|
||||
|
||||
|
||||
async def _resolve_targets(query: str | None):
|
||||
@@ -69,10 +69,8 @@ async def _resolve_targets(query: str | None):
|
||||
|
||||
|
||||
async def _create_reset(user, role_name: str):
|
||||
token = passphrase.generate()
|
||||
expiry = _authsession.reset_expires()
|
||||
db.create_reset_token(
|
||||
passphrase=token,
|
||||
token = db.create_reset_token(
|
||||
user_uuid=user.uuid,
|
||||
expiry=expiry,
|
||||
token_type="manual reset",
|
||||
|
||||
@@ -8,6 +8,8 @@ This module provides FastAPI-specific session management functionality:
|
||||
Generic session management functions have been moved to authsession.py
|
||||
"""
|
||||
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
|
||||
from fastapi import Cookie, Request, Response, WebSocket
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
@@ -16,10 +18,44 @@ AUTH_COOKIE_NAME = "__Host-paskia"
|
||||
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
||||
|
||||
|
||||
def normalize_ip(ip: str) -> str:
|
||||
"""Normalize IP address, stripping brackets and validating format.
|
||||
|
||||
Proxies may pass IPv6 in brackets like [::1] or with zone IDs.
|
||||
IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are converted to plain IPv4.
|
||||
Returns empty string for invalid addresses.
|
||||
"""
|
||||
if not ip:
|
||||
return ""
|
||||
# Strip brackets that some proxies add around IPv6
|
||||
ip = ip.strip("[]")
|
||||
# Strip zone ID (e.g., fe80::1%eth0)
|
||||
if "%" in ip:
|
||||
ip = ip.split("%")[0]
|
||||
try:
|
||||
# Validate and normalize
|
||||
if ":" in ip:
|
||||
addr = IPv6Address(ip)
|
||||
# Convert IPv4-mapped addresses to plain IPv4
|
||||
if addr.ipv4_mapped:
|
||||
return str(addr.ipv4_mapped)
|
||||
return str(addr)
|
||||
return str(IPv4Address(ip))
|
||||
except ValueError:
|
||||
return ip # Return as-is if not a valid IP (could be hostname)
|
||||
|
||||
|
||||
def get_client_ip(request: Request | WebSocket) -> str:
|
||||
"""Get client IP from request, normalized."""
|
||||
if not request.client:
|
||||
return ""
|
||||
return normalize_ip(request.client.host)
|
||||
|
||||
|
||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||
"""Extract client information from request."""
|
||||
return {
|
||||
"ip": request.client.host if request.client else "",
|
||||
"ip": get_client_ip(request),
|
||||
"user_agent": request.headers.get("user-agent", "")[:500],
|
||||
"session_type": type,
|
||||
}
|
||||
|
||||
+25
-4
@@ -17,7 +17,7 @@ from paskia.authsession import (
|
||||
)
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import hostutil
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
@@ -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:
|
||||
@@ -127,11 +149,9 @@ async def api_create_link(
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
ctx = await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
token = passphrase.generate()
|
||||
expiry = expires()
|
||||
db.create_reset_token(
|
||||
token = db.create_reset_token(
|
||||
user_uuid=ctx.user.uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
ctx=ctx,
|
||||
@@ -145,4 +165,5 @@ async def api_create_link(
|
||||
if expiry.tzinfo
|
||||
else expiry.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
"token_type": "device addition",
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_and_login, register_chat
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import passphrase
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
# Create a FastAPI subapp for WebSocket endpoints
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -31,7 +31,7 @@ async def websocket_register_add(
|
||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||
"""
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
host = hostutil.normalize_host(origin.split("://", 1)[1])
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError(
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
"""
|
||||
SQL to JSON migration module for Paskia.
|
||||
|
||||
This module contains the legacy SQL database implementation and migration tools
|
||||
for converting from the old SQLite database to the new JSONL format.
|
||||
|
||||
Usage:
|
||||
python -m paskia.migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl
|
||||
|
||||
Or via the CLI entry point (if installed):
|
||||
paskia-migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import uuid7
|
||||
from sqlalchemy import select
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
User,
|
||||
)
|
||||
|
||||
from .sql import (
|
||||
DB as SQLDB,
|
||||
)
|
||||
from .sql import (
|
||||
CredentialModel,
|
||||
ResetTokenModel,
|
||||
SessionModel,
|
||||
UserModel,
|
||||
)
|
||||
|
||||
# Re-export for convenience
|
||||
__all__ = ["migrate_from_sql", "main", "SQLDB"]
|
||||
|
||||
# Default paths
|
||||
SQL_DB_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
||||
JSON_DB_DEFAULT = "paskia.jsonl"
|
||||
|
||||
|
||||
async def migrate_from_sql(
|
||||
sql_db_path: str = SQL_DB_DEFAULT,
|
||||
json_db_path: str = JSON_DB_DEFAULT,
|
||||
) -> None:
|
||||
"""Migrate data from SQL database to JSON format.
|
||||
|
||||
Args:
|
||||
sql_db_path: SQLAlchemy connection string for the source SQL database
|
||||
json_db_path: Path for the destination JSONL file
|
||||
"""
|
||||
# Initialize source SQL database
|
||||
sql_db = SQLDB(sql_db_path)
|
||||
await sql_db.init_db()
|
||||
|
||||
# Initialize destination JSON database (fresh, don't load existing)
|
||||
db = DB()
|
||||
store = JsonlStore(db, json_db_path)
|
||||
db._store = store
|
||||
|
||||
print(f"Migrating from {sql_db_path} to {json_db_path}...")
|
||||
|
||||
# Build all data directly without saving (we'll save once at the end)
|
||||
# Track old permission ID -> new scope mapping for migration
|
||||
# Also track org-specific admin permissions to consolidate
|
||||
old_org_admin_pattern = re.compile(r"^auth:org:([0-9a-f-]+)$", re.IGNORECASE)
|
||||
org_admin_uuids = set() # org UUIDs that had org-specific admin permissions
|
||||
|
||||
# First pass: identify org-specific admin permissions
|
||||
permissions = await sql_db.list_permissions()
|
||||
for perm in permissions:
|
||||
match = old_org_admin_pattern.match(perm.id)
|
||||
if match:
|
||||
org_admin_uuids.add(match.group(1).lower())
|
||||
|
||||
# Migrate permissions with UUID keys and scope field
|
||||
# Always create exactly one common auth:org:admin permission for all org admin needs
|
||||
org_admin_perm_uuid: UUID = uuid7.create()
|
||||
org_admin_perm = Permission(
|
||||
scope="auth:org:admin",
|
||||
display_name="Org Admin",
|
||||
orgs={},
|
||||
)
|
||||
org_admin_perm.uuid = org_admin_perm_uuid
|
||||
db.permissions[org_admin_perm_uuid] = org_admin_perm
|
||||
|
||||
# Mapping from old permission ID to new permission UUID
|
||||
perm_id_to_uuid: dict[str, UUID] = {}
|
||||
|
||||
for perm in permissions:
|
||||
# Skip old org-specific admin permissions (auth:org:{uuid}) - they map to auth:org:admin
|
||||
match = old_org_admin_pattern.match(perm.id)
|
||||
if match:
|
||||
perm_id_to_uuid[perm.id] = org_admin_perm_uuid
|
||||
continue
|
||||
|
||||
# Skip if this is already auth:org:admin - we created one above
|
||||
if perm.id == "auth:org:admin":
|
||||
perm_id_to_uuid[perm.id] = org_admin_perm_uuid
|
||||
continue
|
||||
|
||||
# Regular permission - create with UUID key
|
||||
perm_uuid: UUID = uuid7.create()
|
||||
new_perm = Permission(
|
||||
scope=perm.id, # Old ID becomes the scope
|
||||
display_name=perm.display_name,
|
||||
orgs={},
|
||||
)
|
||||
new_perm.uuid = perm_uuid
|
||||
db.permissions[perm_uuid] = new_perm
|
||||
perm_id_to_uuid[perm.id] = perm_uuid
|
||||
print(
|
||||
f" Migrated {len(permissions)} permissions (with {len(org_admin_uuids)} org-specific admins consolidated to auth:org:admin)"
|
||||
)
|
||||
|
||||
# Migrate organizations
|
||||
orgs = await sql_db.list_organizations()
|
||||
for org in orgs:
|
||||
org_key: UUID = org.uuid
|
||||
new_org = Org(display_name=org.display_name)
|
||||
new_org.uuid = org_key
|
||||
db.orgs[org_key] = new_org
|
||||
# Update permissions to allow this org to grant them (by UUID)
|
||||
for old_perm_id in org.permissions:
|
||||
perm_uuid = perm_id_to_uuid.get(old_perm_id)
|
||||
if perm_uuid and perm_uuid in db.permissions:
|
||||
db.permissions[perm_uuid].orgs[org_key] = True
|
||||
# Ensure every org can grant auth:org:admin
|
||||
db.permissions[org_admin_perm_uuid].orgs[org_key] = True
|
||||
print(f" Migrated {len(orgs)} organizations")
|
||||
|
||||
# Migrate roles - convert old permission IDs to UUIDs
|
||||
role_count = 0
|
||||
for org in orgs:
|
||||
for role in org.roles:
|
||||
role_key: UUID = role.uuid
|
||||
# Convert old permission IDs to UUIDs
|
||||
new_permissions: dict[UUID, bool] = {}
|
||||
for old_perm_id in role.permissions or []:
|
||||
perm_uuid = perm_id_to_uuid.get(old_perm_id)
|
||||
if perm_uuid:
|
||||
new_permissions[perm_uuid] = True
|
||||
new_role = Role(
|
||||
org_uuid=role.org_uuid,
|
||||
display_name=role.display_name,
|
||||
permissions=new_permissions,
|
||||
)
|
||||
new_role.uuid = role_key
|
||||
db.roles[role_key] = new_role
|
||||
role_count += 1
|
||||
print(f" Migrated {role_count} roles")
|
||||
|
||||
# Migrate users
|
||||
async with sql_db.session() as session:
|
||||
result = await session.execute(select(UserModel))
|
||||
user_models = result.scalars().all()
|
||||
for um in user_models:
|
||||
legacy_user = um.as_dataclass()
|
||||
user_key: UUID = legacy_user.uuid
|
||||
new_user = User(
|
||||
display_name=legacy_user.display_name,
|
||||
role_uuid=legacy_user.role_uuid,
|
||||
created_at=legacy_user.created_at or datetime.now(UTC),
|
||||
last_seen=legacy_user.last_seen,
|
||||
visits=legacy_user.visits,
|
||||
)
|
||||
new_user.uuid = user_key
|
||||
db.users[user_key] = new_user
|
||||
print(f" Migrated {len(user_models)} users")
|
||||
|
||||
# Migrate credentials
|
||||
async with sql_db.session() as session:
|
||||
result = await session.execute(select(CredentialModel))
|
||||
cred_models = result.scalars().all()
|
||||
for cm in cred_models:
|
||||
legacy_cred = cm.as_dataclass()
|
||||
cred_key: UUID = legacy_cred.uuid
|
||||
new_cred = Credential(
|
||||
credential_id=legacy_cred.credential_id,
|
||||
user_uuid=legacy_cred.user_uuid,
|
||||
aaguid=legacy_cred.aaguid,
|
||||
public_key=legacy_cred.public_key,
|
||||
sign_count=legacy_cred.sign_count,
|
||||
created_at=legacy_cred.created_at,
|
||||
last_used=legacy_cred.last_used,
|
||||
last_verified=legacy_cred.last_verified,
|
||||
)
|
||||
new_cred.uuid = cred_key
|
||||
db.credentials[cred_key] = new_cred
|
||||
print(f" Migrated {len(cred_models)} credentials")
|
||||
|
||||
# Migrate sessions
|
||||
# Old format: b"sess" + 12 bytes -> New format: base64url string (16 chars)
|
||||
async with sql_db.session() as session:
|
||||
result = await session.execute(select(SessionModel))
|
||||
session_models = result.scalars().all()
|
||||
for sm in session_models:
|
||||
sess = sm.as_dataclass()
|
||||
old_key: bytes = sess.key
|
||||
# Strip b"sess" prefix and encode remaining 12 bytes as base64url
|
||||
if old_key.startswith(b"sess"):
|
||||
session_key = base64url.enc(old_key[4:])
|
||||
else:
|
||||
# Already in new format or unknown - try to use as-is
|
||||
session_key = base64url.enc(old_key[:12])
|
||||
db.sessions[session_key] = Session(
|
||||
user_uuid=sess.user_uuid,
|
||||
credential_uuid=sess.credential_uuid,
|
||||
host=sess.host,
|
||||
ip=sess.ip,
|
||||
user_agent=sess.user_agent,
|
||||
expiry=sess.renewed + EXPIRES, # Convert renewed to expiry
|
||||
)
|
||||
print(f" Migrated {len(session_models)} sessions")
|
||||
|
||||
# Migrate reset tokens
|
||||
# Old format: b"rset" + 16 bytes hash -> New format: 9 bytes (truncated hash)
|
||||
async with sql_db.session() as session:
|
||||
result = await session.execute(select(ResetTokenModel))
|
||||
token_models = result.scalars().all()
|
||||
for tm in token_models:
|
||||
token = tm.as_dataclass()
|
||||
old_key: bytes = token.key
|
||||
# Strip b"rset" prefix and take first 9 bytes of hash
|
||||
if old_key.startswith(b"rset"):
|
||||
token_key = old_key[4:13] # 9 bytes after prefix
|
||||
else:
|
||||
# Already in new format or unknown - truncate to 9 bytes
|
||||
token_key = old_key[:9]
|
||||
db.reset_tokens[token_key] = ResetToken(
|
||||
user_uuid=token.user_uuid,
|
||||
expiry=token.expiry,
|
||||
token_type=token.token_type,
|
||||
)
|
||||
print(f" Migrated {len(token_models)} reset tokens")
|
||||
|
||||
# Queue and flush all changes using the transaction mechanism
|
||||
with db.transaction("migrate:sql"):
|
||||
pass # All data already added to _data, transaction commits on exit
|
||||
|
||||
await store.flush()
|
||||
|
||||
print("Migration complete!")
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point for migration."""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Migrate Paskia database from SQL to JSON"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sql",
|
||||
default=SQL_DB_DEFAULT,
|
||||
help=f"Source SQL database connection string (default: {SQL_DB_DEFAULT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
default=JSON_DB_DEFAULT,
|
||||
help=f"Destination JSONL file path (default: {JSON_DB_DEFAULT})",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(migrate_from_sql(args.sql, args.json))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,438 +0,0 @@
|
||||
"""
|
||||
Legacy SQL database implementation for migration purposes.
|
||||
|
||||
This module provides the async SQLAlchemy database layer that was used
|
||||
before the JSONL format. It is kept here for migration purposes only.
|
||||
|
||||
DO NOT use this module for new code. Use paskia.db instead.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
event,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import BLOB
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
# Legacy User class for SQL schema (uses 'role_uuid' not 'role')
|
||||
@dataclass
|
||||
class _LegacyUser:
|
||||
"""User as stored in the old SQL schema with role_uuid field."""
|
||||
|
||||
uuid: UUID
|
||||
display_name: str
|
||||
role_uuid: UUID
|
||||
created_at: datetime | None = None
|
||||
last_seen: datetime | None = None
|
||||
visits: int = 0
|
||||
|
||||
|
||||
# Legacy Credential class for SQL schema (uses 'user_uuid' not 'user')
|
||||
@dataclass
|
||||
class _LegacyCredential:
|
||||
"""Credential as stored in the old SQL schema with user_uuid field."""
|
||||
|
||||
uuid: UUID
|
||||
credential_id: bytes
|
||||
user_uuid: UUID
|
||||
aaguid: UUID
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
created_at: datetime
|
||||
last_used: datetime | None = None
|
||||
last_verified: datetime | None = None
|
||||
|
||||
|
||||
# Legacy Role class for SQL schema (uses 'org_uuid' not 'org')
|
||||
@dataclass
|
||||
class _LegacyRole:
|
||||
"""Role as stored in the old SQL schema with org_uuid field."""
|
||||
|
||||
uuid: UUID
|
||||
org_uuid: UUID
|
||||
display_name: str
|
||||
permissions: list[str] | None = None
|
||||
|
||||
|
||||
# Legacy Org class for SQL schema (has mutable permissions/roles lists)
|
||||
@dataclass
|
||||
class _LegacyOrg:
|
||||
"""Org as stored in the old SQL schema with mutable permissions/roles."""
|
||||
|
||||
uuid: UUID
|
||||
display_name: str
|
||||
permissions: list[str] | None = None
|
||||
roles: list[_LegacyRole] | None = None
|
||||
|
||||
|
||||
# Legacy Session class for SQL schema (uses 'key' as field, 'user_uuid', 'credential_uuid')
|
||||
@dataclass
|
||||
class _LegacySession:
|
||||
"""Session as stored in the old SQL schema."""
|
||||
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
credential_uuid: UUID
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
renewed: datetime
|
||||
|
||||
|
||||
# Legacy ResetToken class for SQL schema (uses 'key' as field, 'user_uuid')
|
||||
@dataclass
|
||||
class _LegacyResetToken:
|
||||
"""ResetToken as stored in the old SQL schema."""
|
||||
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
token_type: str
|
||||
expiry: datetime
|
||||
|
||||
|
||||
# Local Permission class for SQL schema (uses 'id' not 'uuid' + 'scope')
|
||||
@dataclass
|
||||
class SqlPermission:
|
||||
"""Permission as stored in the old SQL schema with id field."""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
|
||||
|
||||
DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
||||
|
||||
|
||||
def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class OrgModel(Base):
|
||||
__tablename__ = "orgs"
|
||||
|
||||
uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
def as_dataclass(self):
|
||||
# Base Org without permissions/roles (filled by data accessors)
|
||||
return _LegacyOrg(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
display_name=self.display_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(org: _LegacyOrg):
|
||||
return OrgModel(uuid=org.uuid.bytes, display_name=org.display_name)
|
||||
|
||||
|
||||
class RoleModel(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
org_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
def as_dataclass(self):
|
||||
# Base Role without permissions (filled by data accessors)
|
||||
return _LegacyRole(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
org_uuid=UUID(bytes=self.org_uuid),
|
||||
display_name=self.display_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(role: _LegacyRole):
|
||||
return RoleModel(
|
||||
uuid=role.uuid.bytes,
|
||||
org_uuid=role.org_uuid.bytes,
|
||||
display_name=role.display_name,
|
||||
)
|
||||
|
||||
|
||||
class UserModel(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
role_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
def as_dataclass(self) -> "_LegacyUser":
|
||||
return _LegacyUser(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
display_name=self.display_name,
|
||||
role_uuid=UUID(bytes=self.role_uuid),
|
||||
created_at=_normalize_dt(self.created_at) or self.created_at,
|
||||
last_seen=_normalize_dt(self.last_seen) or self.last_seen,
|
||||
visits=self.visits,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(user: "_LegacyUser"):
|
||||
return UserModel(
|
||||
uuid=user.uuid.bytes,
|
||||
display_name=user.display_name,
|
||||
role_uuid=user.role_uuid.bytes,
|
||||
created_at=user.created_at or datetime.now(UTC),
|
||||
last_seen=user.last_seen,
|
||||
visits=user.visits,
|
||||
)
|
||||
|
||||
|
||||
class CredentialModel(Base):
|
||||
__tablename__ = "credentials"
|
||||
|
||||
uuid: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
credential_id: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(64), unique=True, index=True
|
||||
)
|
||||
user_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE")
|
||||
)
|
||||
aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False)
|
||||
public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False)
|
||||
sign_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC)
|
||||
)
|
||||
last_used: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_verified: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def as_dataclass(self):
|
||||
return _LegacyCredential(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
credential_id=self.credential_id,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
aaguid=UUID(bytes=self.aaguid),
|
||||
public_key=self.public_key,
|
||||
sign_count=self.sign_count,
|
||||
created_at=_normalize_dt(self.created_at) or self.created_at,
|
||||
last_used=_normalize_dt(self.last_used) or self.last_used,
|
||||
last_verified=_normalize_dt(self.last_verified) or self.last_verified,
|
||||
)
|
||||
|
||||
|
||||
class SessionModel(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
user_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
credential_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16),
|
||||
ForeignKey("credentials.uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
host: Mapped[str] = mapped_column(String, nullable=False)
|
||||
ip: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
renewed: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def as_dataclass(self):
|
||||
return _LegacySession(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
credential_uuid=UUID(bytes=self.credential_uuid),
|
||||
host=self.host,
|
||||
ip=self.ip,
|
||||
user_agent=self.user_agent,
|
||||
renewed=_normalize_dt(self.renewed) or self.renewed,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(session: _LegacySession):
|
||||
return SessionModel(
|
||||
key=session.key,
|
||||
user_uuid=session.user_uuid.bytes,
|
||||
credential_uuid=session.credential_uuid.bytes,
|
||||
host=session.host,
|
||||
ip=session.ip,
|
||||
user_agent=session.user_agent,
|
||||
renewed=session.renewed,
|
||||
)
|
||||
|
||||
|
||||
class ResetTokenModel(Base):
|
||||
__tablename__ = "reset_tokens"
|
||||
|
||||
key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True)
|
||||
user_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def as_dataclass(self) -> _LegacyResetToken:
|
||||
return _LegacyResetToken(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
token_type=self.token_type,
|
||||
expiry=_normalize_dt(self.expiry) or self.expiry,
|
||||
)
|
||||
|
||||
|
||||
class PermissionModel(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
def as_dataclass(self):
|
||||
return SqlPermission(self.id, self.display_name)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(permission: SqlPermission):
|
||||
return PermissionModel(
|
||||
id=permission.id,
|
||||
display_name=permission.display_name,
|
||||
)
|
||||
|
||||
|
||||
class OrgPermission(Base):
|
||||
"""Permissions each organization is allowed to grant to its roles."""
|
||||
|
||||
__tablename__ = "org_permissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
org_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("orgs.uuid", ondelete="CASCADE")
|
||||
)
|
||||
permission_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("permissions.id", ondelete="CASCADE")
|
||||
)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""Permissions that each role grants to its members."""
|
||||
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
role_uuid: Mapped[bytes] = mapped_column(
|
||||
LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE")
|
||||
)
|
||||
permission_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("permissions.id", ondelete="CASCADE")
|
||||
)
|
||||
|
||||
|
||||
class DB:
|
||||
"""Legacy SQL database class for migration purposes only."""
|
||||
|
||||
def __init__(self, db_path: str = DB_PATH_DEFAULT):
|
||||
"""Initialize with database path."""
|
||||
self.engine = create_async_engine(db_path, echo=False)
|
||||
# Ensure SQLite foreign key enforcement is ON for every new connection
|
||||
if db_path.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(self.engine.sync_engine, "connect")
|
||||
def _fk_on(dbapi_connection, connection_record):
|
||||
try:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON;")
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.async_session_factory = async_sessionmaker(
|
||||
self.engine, expire_on_commit=False
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self):
|
||||
"""Async context manager that provides a database session with transaction."""
|
||||
async with self.async_session_factory() as session:
|
||||
async with session.begin():
|
||||
yield session
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
async def init_db(self) -> None:
|
||||
"""Initialize database tables."""
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async def list_permissions(self) -> list[SqlPermission]:
|
||||
async with self.session() as session:
|
||||
result = await session.execute(select(PermissionModel))
|
||||
return [p.as_dataclass() for p in result.scalars().all()]
|
||||
|
||||
async def list_organizations(self) -> list[_LegacyOrg]:
|
||||
async with self.session() as session:
|
||||
# Load all orgs
|
||||
orgs_result = await session.execute(select(OrgModel))
|
||||
org_models = orgs_result.scalars().all()
|
||||
if not org_models:
|
||||
return []
|
||||
|
||||
# Preload org permissions mapping
|
||||
org_perms_result = await session.execute(select(OrgPermission))
|
||||
org_perms = org_perms_result.scalars().all()
|
||||
perms_by_org: dict[bytes, list[str]] = {}
|
||||
for op in org_perms:
|
||||
perms_by_org.setdefault(op.org_uuid, []).append(op.permission_id)
|
||||
|
||||
# Preload roles
|
||||
roles_result = await session.execute(select(RoleModel))
|
||||
role_models = roles_result.scalars().all()
|
||||
|
||||
# Preload role permissions mapping
|
||||
rp_result = await session.execute(select(RolePermission))
|
||||
rps = rp_result.scalars().all()
|
||||
perms_by_role: dict[bytes, list[str]] = {}
|
||||
for rp in rps:
|
||||
perms_by_role.setdefault(rp.role_uuid, []).append(rp.permission_id)
|
||||
|
||||
# Build org dataclasses with roles and permission IDs
|
||||
roles_by_org: dict[bytes, list[_LegacyRole]] = {}
|
||||
for rm in role_models:
|
||||
r_dc = rm.as_dataclass()
|
||||
r_dc.permissions = perms_by_role.get(rm.uuid, [])
|
||||
roles_by_org.setdefault(rm.org_uuid, []).append(r_dc)
|
||||
|
||||
orgs: list[_LegacyOrg] = []
|
||||
for om in org_models:
|
||||
o_dc = om.as_dataclass()
|
||||
o_dc.permissions = perms_by_org.get(om.uuid, [])
|
||||
o_dc.roles = roles_by_org.get(om.uuid, [])
|
||||
orgs.append(o_dc)
|
||||
|
||||
return orgs
|
||||
@@ -56,7 +56,7 @@ def reload_config() -> None:
|
||||
|
||||
|
||||
def normalize_host(raw_host: str | None) -> str | None:
|
||||
"""Normalize a Host header preserving port (exact match required)."""
|
||||
"""Normalize a Host header, stripping port numbers for consistent matching."""
|
||||
if not raw_host:
|
||||
return None
|
||||
candidate = raw_host.strip()
|
||||
@@ -65,11 +65,12 @@ def normalize_host(raw_host: str | None) -> str | None:
|
||||
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
|
||||
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
|
||||
netloc = parsed.netloc or parsed.path or ""
|
||||
# Strip IPv6 brackets around host part but retain port suffix.
|
||||
# Handle IPv6 addresses: [ipv6]:port or [ipv6]
|
||||
if netloc.startswith("["):
|
||||
# format: [ipv6]:port or [ipv6]
|
||||
if "]" in netloc:
|
||||
host_part, _, rest = netloc.partition("]")
|
||||
port_part = rest.lstrip(":")
|
||||
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
|
||||
host_part, _, _ = netloc.partition("]")
|
||||
netloc = host_part.strip("[]")
|
||||
else:
|
||||
# Strip port from host:port
|
||||
netloc = netloc.rsplit(":", 1)[0]
|
||||
return netloc.lower() or None
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import user_agents
|
||||
from ua_parser import parse
|
||||
|
||||
|
||||
def compact_user_agent(ua: str | None) -> str:
|
||||
if not ua:
|
||||
return "-"
|
||||
u = user_agents.parse(ua)
|
||||
ver = u.browser.version_string.split(".")[0]
|
||||
dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
|
||||
return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
|
||||
"""Format user agent string into a compact display format.
|
||||
|
||||
Returns empty string for empty/missing user agents.
|
||||
Returns original UA for unrecognized ones.
|
||||
"""
|
||||
if not ua or not ua.strip() or ua == "-":
|
||||
return ""
|
||||
r = parse(ua)
|
||||
browser = r.user_agent.family if r.user_agent else None
|
||||
ver = r.user_agent.major if r.user_agent else ""
|
||||
os_name = r.os.family if r.os else None
|
||||
dev = r.device.family if r.device else None
|
||||
# If browser is unrecognized, return original UA
|
||||
if browser in (None, "Other") and os_name in (None, "Other"):
|
||||
return ua
|
||||
# Filter out "Other" values
|
||||
browser = browser if browser and browser != "Other" else ""
|
||||
os_name = os_name if os_name and os_name != "Other" else ""
|
||||
# Exclude device if it's "Other" or matches browser family (parser bug)
|
||||
if dev in (None, "Other") or dev == browser:
|
||||
dev = ""
|
||||
# Build compact string, filtering empty parts
|
||||
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
|
||||
result = " ".join(p for p in parts if p).strip()
|
||||
return result
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-6
@@ -18,11 +18,11 @@ dependencies = [
|
||||
"base64url>=1.0.0",
|
||||
"uuid7-standard>=1.0.0",
|
||||
"pyjwt>=2.8.0",
|
||||
"user-agents>=2.2.0",
|
||||
"jsondiff>=2.2.1",
|
||||
"msgspec>=0.20.0",
|
||||
"aiofiles>=25.1.0",
|
||||
"fastapi-vue>=0.3.0",
|
||||
"ua-parser[regex]>=1.0.1",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -44,10 +44,6 @@ dev = [
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
migrate = [
|
||||
"sqlalchemy[asyncio]>=2.0.0",
|
||||
"aiosqlite>=0.19.0",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["paskia"]
|
||||
@@ -91,7 +87,6 @@ dev = [
|
||||
|
||||
[project.scripts]
|
||||
paskia = "paskia.fastapi.__main__:main"
|
||||
paskia-migrate = "paskia.migrate:main"
|
||||
|
||||
[tool.hatch.build]
|
||||
artifacts = ["paskia/frontend-build"]
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
"""Hatch build hook for building paskia-js and Vue frontend during package build."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
|
||||
# Import utilities from fastapi-vue
|
||||
exec(Path(__file__).parent.joinpath("fastapi-vue", "util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
"""Run a command and display it."""
|
||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||
stderr.write(f"### {' '.join(display_cmd)}\n")
|
||||
subprocess.run(cmd, check=True, **kwargs)
|
||||
sys.path.insert(0, str(Path(__file__).parent / "fastapi-vue"))
|
||||
from buildutil import build # noqa: E402
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
@@ -22,27 +14,5 @@ class CustomBuildHook(BuildHookInterface):
|
||||
|
||||
def initialize(self, version, build_data):
|
||||
super().initialize(version, build_data)
|
||||
stderr.write(">>> Building paskia-js library\n")
|
||||
|
||||
install_cmd, build_cmd = find_build_tool() # noqa: F821 # type: ignore
|
||||
|
||||
try:
|
||||
# Install dependencies for paskia-js
|
||||
run(install_cmd, cwd="paskia-js")
|
||||
stderr.write("\n")
|
||||
# Build paskia-js
|
||||
run(build_cmd, cwd="paskia-js")
|
||||
stderr.write("\n")
|
||||
except Exception as e:
|
||||
stderr.write(f"Error occurred while building paskia-js: {e}\n")
|
||||
raise
|
||||
|
||||
stderr.write(">>> Building the frontend\n")
|
||||
|
||||
try:
|
||||
run(install_cmd, cwd="frontend")
|
||||
stderr.write("\n")
|
||||
run(build_cmd, cwd="frontend")
|
||||
except Exception as e:
|
||||
stderr.write(f"Error occurred while building frontend: {e}\n")
|
||||
raise
|
||||
build("paskia-js")
|
||||
build("frontend")
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||
|
||||
Usage:
|
||||
uv run scripts/devserver.py [host:port] [--backend host:port]
|
||||
|
||||
The optional host:port argument sets where the Vite frontend listens.
|
||||
Supported forms: host[:port], :port (all interfaces), or just port.
|
||||
The --backend option sets where the FastAPI backend listens (default: localhost:5180).
|
||||
|
||||
Environment:
|
||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
import httpx
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
DEFAULT_VITE_PORT = 5173
|
||||
DEFAULT_BACKEND_PORT = 5180
|
||||
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||
|
||||
EPILOG = """
|
||||
scripts/devserver.py # Default ports on localhost
|
||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||
scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000
|
||||
"""
|
||||
|
||||
BUN_BUG = """\
|
||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||
┃
|
||||
┃ Consider using deno or npm instead for development.
|
||||
"""
|
||||
|
||||
|
||||
def resolve_frontend_tools(
|
||||
vite_port: int, all_ifaces: bool
|
||||
) -> tuple[list[str], list[str], str]:
|
||||
"""Resolve frontend install and dev commands.
|
||||
|
||||
Returns (install_cmd, dev_cmd, tool_name).
|
||||
Raises SystemExit if tools are not available.
|
||||
"""
|
||||
if not (FRONTEND_PATH / "package.json").exists():
|
||||
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
result = find_js_runtime() # noqa # type: ignore
|
||||
if result is None:
|
||||
if not os.environ.get("JS_RUNTIME"):
|
||||
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
tool, name = result
|
||||
|
||||
install_args = {
|
||||
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install", "--silent"),
|
||||
"bun": ("install", "--silent"),
|
||||
}
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
install_cmd = [tool, *install_args[name]]
|
||||
dev_cmd = [
|
||||
tool,
|
||||
*dev_args[name],
|
||||
"--clearScreen=false",
|
||||
f"--port={vite_port}",
|
||||
]
|
||||
|
||||
if all_ifaces:
|
||||
dev_cmd.append("--host")
|
||||
|
||||
if name == "bun":
|
||||
stderr.write(BUN_BUG)
|
||||
|
||||
return install_cmd, dev_cmd, name
|
||||
|
||||
|
||||
async def wait_for_backend(host: str, port: int):
|
||||
"""Wait for the backend to be ready by polling the health endpoint."""
|
||||
max_attempts = 50
|
||||
url = f"http://{host}:{port}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(url, timeout=1.0)
|
||||
stderr.write("✓ Backend ready!\n")
|
||||
return True
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
stderr.write("┃ ⚠️ Backend didn't start in time\n")
|
||||
return False
|
||||
await asyncio.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
|
||||
"""Gracefully terminate a subprocess."""
|
||||
if proc.returncode is not None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||
except TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
await proc.wait()
|
||||
|
||||
|
||||
async def run_devserver(
|
||||
vite_port: int,
|
||||
all_ifaces: bool,
|
||||
backend_host: str,
|
||||
backend_port: int,
|
||||
) -> None:
|
||||
"""Run the development server with install, backend, and frontend."""
|
||||
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces)
|
||||
|
||||
# Tell the backend where the Vite dev server is
|
||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}"
|
||||
# Tell Vite where the backend is (for proxying /api requests)
|
||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
||||
|
||||
backend_cmd = [
|
||||
"uvicorn",
|
||||
"paskia.app:app",
|
||||
"--host",
|
||||
backend_host,
|
||||
"--port",
|
||||
str(backend_port),
|
||||
"--reload",
|
||||
]
|
||||
|
||||
cwd = str(Path(__file__).parent.parent)
|
||||
frontend_cwd = str(FRONTEND_PATH)
|
||||
|
||||
backend_proc: asyncio.subprocess.Process | None = None
|
||||
install_proc: asyncio.subprocess.Process | None = None
|
||||
frontend_proc: asyncio.subprocess.Process | None = None
|
||||
|
||||
try:
|
||||
# Start install (concurrent with backend)
|
||||
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
|
||||
install_proc = await asyncio.create_subprocess_exec(
|
||||
*install_cmd, cwd=frontend_cwd
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Start backend (concurrent with install)
|
||||
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
||||
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
|
||||
|
||||
# Wait for install to complete and backend to be ready
|
||||
install_task = asyncio.create_task(install_proc.wait(), name="install")
|
||||
backend_ready_task = asyncio.create_task(
|
||||
wait_for_backend(backend_host, backend_port), name="backend_ready"
|
||||
)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{install_task, backend_ready_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
for task in done:
|
||||
if task.get_name() == "install":
|
||||
if task.result() != 0:
|
||||
stderr.write("┃ ⚠️ Install failed\n")
|
||||
raise SystemExit(1)
|
||||
elif task.get_name() == "backend_ready" and not task.result():
|
||||
raise SystemExit(1)
|
||||
|
||||
if pending:
|
||||
done2, _ = await asyncio.wait(pending)
|
||||
for task in done2:
|
||||
if task.get_name() == "install":
|
||||
if task.result() != 0:
|
||||
stderr.write("┃ ⚠️ Install failed\n")
|
||||
raise SystemExit(1)
|
||||
elif task.get_name() == "backend_ready" and not task.result():
|
||||
raise SystemExit(1)
|
||||
|
||||
install_proc = None
|
||||
|
||||
# Start Vite dev server
|
||||
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
|
||||
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
|
||||
|
||||
# Wait for either process to exit
|
||||
done, pending = await asyncio.wait(
|
||||
{
|
||||
asyncio.create_task(backend_proc.wait(), name="backend"),
|
||||
asyncio.create_task(frontend_proc.wait(), name="frontend"),
|
||||
},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in done:
|
||||
t.result()
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
stderr.write("\n✓ Shutting down...\n")
|
||||
finally:
|
||||
if frontend_proc is not None:
|
||||
await _terminate_process(frontend_proc, "frontend")
|
||||
if install_proc is not None:
|
||||
await _terminate_process(install_proc, "install")
|
||||
if backend_proc is not None:
|
||||
await _terminate_process(backend_proc, "backend")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"frontend",
|
||||
nargs="?",
|
||||
metavar="host:port",
|
||||
help="Vite frontend endpoint (default: localhost:5173)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="host:port",
|
||||
help="FastAPI backend endpoint (default: localhost:5180)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# parse_endpoint returns list of dicts with host/port or uds keys
|
||||
# Multiple entries means bind all interfaces (IPv4 + IPv6)
|
||||
vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
||||
backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
||||
|
||||
# Vite doesn't support unix sockets
|
||||
if "uds" in vite_endpoints[0]:
|
||||
stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n")
|
||||
raise SystemExit(1)
|
||||
if "uds" in backend_endpoints[0]:
|
||||
stderr.write("┃ ⚠️ Unix sockets not supported for backend\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
vite_port = vite_endpoints[0]["port"]
|
||||
all_ifaces = len(vite_endpoints) > 1
|
||||
backend_host = backend_endpoints[0]["host"]
|
||||
backend_port = backend_endpoints[0]["port"]
|
||||
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+191
-365
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||
"""Run Vite development server for frontend and Paskia backend with auto-reload.
|
||||
|
||||
This script is only available when running from the git repository source,
|
||||
not from the installed package. It starts both the Vite frontend dev server
|
||||
and the FastAPI backend with auto-reload enabled.
|
||||
and the Paskia 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.
|
||||
|
||||
@@ -25,45 +25,26 @@ Options:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import ipaddress
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from threading import Thread
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
# Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from buildutil import find_dev_tool, find_install_tool, logger # noqa: E402
|
||||
from devutil import ProcessGroup, check_ports_free # noqa: E402
|
||||
|
||||
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
|
||||
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
|
||||
CADDY_PORT = 443 # HTTPS port for Caddy proxy
|
||||
CADDY_HTTP_PORT = 80 # HTTP port for ACME challenges
|
||||
DEFAULT_HOST = "localhost"
|
||||
|
||||
NO_FRONTEND_TOOL = """\
|
||||
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
|
||||
"""
|
||||
|
||||
BUN_BUG = """\
|
||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||
┃
|
||||
┃ Options:
|
||||
┃ - sudo caddy run --config caddy/Caddyfile.dev
|
||||
┃ - Install deno or npm instead
|
||||
┃
|
||||
┃ Caddy will skip the Vite for API calls and serve everything at port 443.
|
||||
┃ Otherwise Vite serves at port 8077 and proxies to backend (broken with bun).
|
||||
"""
|
||||
|
||||
NO_FRONTEND = """\
|
||||
┃
|
||||
┃ The backend will still try reaching Vite at {vite_url}
|
||||
┃ for various frontend assets, so make sure to start it manually.
|
||||
"""
|
||||
|
||||
CADDYFILE_SITE_BLOCK = """\
|
||||
SITE_ADDR {
|
||||
@@ -79,140 +60,43 @@ SITE_ADDR {
|
||||
"""
|
||||
|
||||
|
||||
def parse_endpoint(
|
||||
value: str | None, default_port: int
|
||||
) -> tuple[str | None, int | None, str | None, bool]:
|
||||
"""Parse an endpoint for Vite (simplified version for dev.py).
|
||||
|
||||
Returns (host, port, uds_path, all_ifaces).
|
||||
"""
|
||||
if not value:
|
||||
return DEFAULT_HOST, default_port, None, False
|
||||
|
||||
# Port only (numeric) -> localhost:port
|
||||
if value.isdigit():
|
||||
return DEFAULT_HOST, int(value), None, False
|
||||
|
||||
# Leading colon :port -> bind all interfaces
|
||||
if value.startswith(":") and value != ":":
|
||||
port_part = value[1:]
|
||||
if not port_part.isdigit():
|
||||
raise SystemExit(f"Invalid port in '{value}'")
|
||||
return None, int(port_part), None, True
|
||||
|
||||
# UNIX domain socket
|
||||
if value.startswith("unix:"):
|
||||
uds_path = value[5:] or None
|
||||
if uds_path is None:
|
||||
raise SystemExit("unix: path must not be empty")
|
||||
return None, None, uds_path, False
|
||||
|
||||
# Unbracketed IPv6 (cannot safely contain a port)
|
||||
if value.count(":") > 1 and not value.startswith("["):
|
||||
try:
|
||||
ipaddress.IPv6Address(value)
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}")
|
||||
return value, default_port, None, False
|
||||
|
||||
# Use urllib.parse for everything else
|
||||
parsed = urlparse(f"//{value}")
|
||||
host = parsed.hostname or DEFAULT_HOST
|
||||
port = parsed.port or default_port
|
||||
|
||||
return host, port, None, False
|
||||
|
||||
|
||||
def run_vite(
|
||||
vite_url: str,
|
||||
vite_host: str | None,
|
||||
vite_port: int,
|
||||
env: dict,
|
||||
auth_host: str | None = None,
|
||||
):
|
||||
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
|
||||
def build_vite_cmd(vite_host: str, vite_port: int) -> list[str] | None:
|
||||
"""Build the Vite dev command, or None if not available."""
|
||||
devpath = Path(__file__).parent.parent / "frontend"
|
||||
if not (devpath / "package.json").exists():
|
||||
stderr.write(
|
||||
f"┃ ⚠️ Frontend source not found at {devpath}\n"
|
||||
+ NO_FRONTEND.format(vite_url=vite_url)
|
||||
)
|
||||
return
|
||||
|
||||
options = [
|
||||
("deno", "run", "dev"),
|
||||
("npm", "--silent", "run", "dev", "--"),
|
||||
("bunx", "--bun", "vite"),
|
||||
]
|
||||
cmd = None
|
||||
tool_name = None
|
||||
for option in options:
|
||||
if tool := shutil.which(option[0]):
|
||||
cmd = [tool, *option[1:]]
|
||||
tool_name = option[0]
|
||||
break
|
||||
|
||||
# Add Vite CLI args for host/port
|
||||
vite_args = ["--port", str(vite_port), "--logLevel", "silent"]
|
||||
if vite_host:
|
||||
vite_args.extend(["--host", vite_host])
|
||||
|
||||
vite_process = None
|
||||
|
||||
def start_vite():
|
||||
nonlocal vite_process
|
||||
if cmd is None:
|
||||
stderr.write(NO_FRONTEND_TOOL + NO_FRONTEND.format(vite_url=vite_url))
|
||||
return
|
||||
assert tool_name is not None
|
||||
try:
|
||||
if tool_name == "bunx":
|
||||
stderr.write(BUN_BUG)
|
||||
|
||||
full_cmd = cmd + vite_args
|
||||
stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n")
|
||||
vite_env = env.copy()
|
||||
if auth_host:
|
||||
vite_env["PASKIA_AUTH_HOST"] = auth_host
|
||||
vite_process = subprocess.Popen(
|
||||
full_cmd, cwd=str(devpath), shell=False, env=vite_env
|
||||
)
|
||||
except Exception as e:
|
||||
stderr.write(
|
||||
f"┃ ⚠️ Vite couldn't start: {e}\n"
|
||||
+ NO_FRONTEND.format(vite_url=vite_url)
|
||||
)
|
||||
|
||||
def cleanup():
|
||||
if vite_process:
|
||||
vite_process.terminate()
|
||||
vite_process.wait()
|
||||
|
||||
# Start Vite in a separate thread
|
||||
vite_thread = Thread(target=start_vite, daemon=True)
|
||||
vite_thread.start()
|
||||
|
||||
atexit.register(cleanup)
|
||||
signal.signal(signal.SIGTERM, lambda *_: cleanup())
|
||||
signal.signal(signal.SIGINT, lambda *_: cleanup())
|
||||
|
||||
|
||||
def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
|
||||
"""Spawn Caddy as HTTPS reverse proxy for the given origins."""
|
||||
caddy_path = shutil.which("caddy")
|
||||
if not caddy_path:
|
||||
stderr.write("┃ ⚠️ Caddy not found. Install it to use --caddy option.\n")
|
||||
logger.warning("Frontend source not found at %s", devpath)
|
||||
return None
|
||||
|
||||
# Build Caddyfile with a site block for each origin
|
||||
try:
|
||||
cmd = find_dev_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(str(e))
|
||||
return None
|
||||
|
||||
# Add Vite CLI args for host/port
|
||||
cmd.extend([f"--port={vite_port}", "--logLevel=silent"])
|
||||
if vite_host and vite_host != "localhost":
|
||||
cmd.append("--host" if vite_host == "0.0.0.0" else f"--host={vite_host}")
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def build_npm_install_cmd() -> list[str] | None:
|
||||
"""Build the npm install command, or None if not available."""
|
||||
try:
|
||||
return find_install_tool()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def build_caddyfile(origins: list[str], vite_port: int) -> str:
|
||||
"""Build a Caddyfile for the given origins."""
|
||||
caddyfile_parts = []
|
||||
for origin in origins:
|
||||
parsed = urlparse(origin)
|
||||
# Extract scheme://host:port from origin URL
|
||||
scheme = parsed.scheme or "https"
|
||||
host = parsed.hostname or parsed.path # handle case without scheme
|
||||
host = parsed.hostname or parsed.path
|
||||
port = parsed.port or (CADDY_HTTP_PORT if scheme == "http" else CADDY_PORT)
|
||||
# Use standard ports without explicit port in address (cleaner URLs)
|
||||
if port in (80, 443):
|
||||
site_addr = f"{scheme}://{host}"
|
||||
else:
|
||||
@@ -223,162 +107,181 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
|
||||
.replace("VITE_PORT", str(vite_port))
|
||||
)
|
||||
caddyfile_parts.append(block)
|
||||
return "\n".join(caddyfile_parts)
|
||||
|
||||
caddyfile = "\n".join(caddyfile_parts)
|
||||
caddy_process = None
|
||||
|
||||
try:
|
||||
# Use sudo to bind to privileged ports (80/443) for ACME certificate fetching
|
||||
cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"]
|
||||
caddy_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
caddy_process.stdin.write(caddyfile.encode())
|
||||
caddy_process.stdin.close()
|
||||
except Exception as e:
|
||||
stderr.write(f"┃ ⚠️ Caddy couldn't start: {e}\n")
|
||||
return None
|
||||
async def run_caddy(origins: list[str], vite_port: int) -> asyncio.subprocess.Process:
|
||||
"""Start Caddy as HTTPS reverse proxy, wait for ready signal."""
|
||||
caddy_path = shutil.which("caddy")
|
||||
if not caddy_path:
|
||||
logger.warning("Caddy not found. Install it to use --caddy option.")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Helper to parse Caddy log line (JSON or plain text) into (level, logger, msg)
|
||||
def parse_caddy_log(line: str) -> tuple[str, str, str] | None:
|
||||
"""Parse a Caddy log line, return (level, logger, msg) or None if unparseable."""
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
return None
|
||||
caddyfile = build_caddyfile(origins, vite_port)
|
||||
cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"]
|
||||
|
||||
# Try JSON format first
|
||||
try:
|
||||
log = json.loads(line)
|
||||
return (
|
||||
log.get("level", ""),
|
||||
log.get("logger", ""),
|
||||
log.get("msg", ""),
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
logger.info(">>> sudo caddy @ %s", " ".join(origins))
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
proc.stdin.write(caddyfile.encode())
|
||||
await proc.stdin.drain()
|
||||
proc.stdin.close()
|
||||
|
||||
# Plain text format: "2025/12/06 22:59:41.390 INFO logger msg..."
|
||||
# or "2025/12/06 22:59:41.390 INFO msg..." (no logger)
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
# First part is "timestamp LEVEL", rest are logger and/or message
|
||||
first = parts[0].rsplit(None, 1) # split off the level from timestamp
|
||||
if len(first) == 2:
|
||||
level = first[1].lower()
|
||||
if len(parts) == 2:
|
||||
return (level, "", parts[1])
|
||||
else:
|
||||
return (level, parts[1], "\t".join(parts[2:]))
|
||||
|
||||
# Unparseable - return as-is with no level/logger
|
||||
return ("", "", line)
|
||||
|
||||
def strip_caddy_verbose(msg: str) -> str:
|
||||
"""Remove verbose prefixes from Caddy error messages."""
|
||||
return msg.replace("loading initial config: loading new config: ", "")
|
||||
|
||||
def format_caddy_log(level: str, logger: str, msg: str) -> str:
|
||||
"""Format a parsed Caddy log for display."""
|
||||
msg = strip_caddy_verbose(msg)
|
||||
if logger:
|
||||
return f"┃ [{level.upper()}] {logger}: {msg}\n"
|
||||
else:
|
||||
return f"┃ [{level.upper()}] {msg}\n"
|
||||
|
||||
# Read stderr line by line until Caddy signals it's ready or exits
|
||||
# Caddy outputs logs; "serving initial configuration" means it's ready
|
||||
# Wait for ready signal or failure
|
||||
while True:
|
||||
exit_code = caddy_process.poll()
|
||||
if exit_code is not None:
|
||||
# Process exited - read remaining stderr and report failure
|
||||
remaining = (
|
||||
caddy_process.stderr.read().decode() if caddy_process.stderr else ""
|
||||
)
|
||||
if remaining:
|
||||
for line in remaining.splitlines():
|
||||
if line:
|
||||
parsed = parse_caddy_log(line)
|
||||
if parsed:
|
||||
level, logger, msg = parsed
|
||||
if level:
|
||||
stderr.write(format_caddy_log(level, logger, msg))
|
||||
else:
|
||||
stderr.write(f"┃ {strip_caddy_verbose(msg)}\n")
|
||||
else:
|
||||
stderr.write(f"┃ {strip_caddy_verbose(line)}\n")
|
||||
stderr.write(f"┃ ⚠️ Caddy startup failed (exit code {exit_code})\n")
|
||||
return None
|
||||
if proc.returncode is not None:
|
||||
remaining = await proc.stderr.read()
|
||||
for line in remaining.decode().splitlines():
|
||||
if line:
|
||||
logger.info("caddy: %s", line)
|
||||
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
|
||||
raise SystemExit(1)
|
||||
|
||||
# Read one line from stderr (blocks until data available)
|
||||
line = caddy_process.stderr.readline().decode()
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check for ready signal
|
||||
if "serving initial configuration" in line:
|
||||
decoded = line.decode().rstrip()
|
||||
if "serving initial configuration" in decoded:
|
||||
break
|
||||
|
||||
parsed = parse_caddy_log(line)
|
||||
if not parsed:
|
||||
continue
|
||||
# Parse and show errors during startup
|
||||
if decoded:
|
||||
try:
|
||||
log = json.loads(decoded)
|
||||
level = log.get("level", "")
|
||||
if level in ("error", "fatal", "warn"):
|
||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||
except json.JSONDecodeError:
|
||||
if "error" in decoded.lower() or "fatal" in decoded.lower():
|
||||
logger.warning("caddy: %s", decoded)
|
||||
|
||||
level, logger, msg = parsed
|
||||
|
||||
# Filter out info-level and admin messages
|
||||
if level == "info" or logger == "admin":
|
||||
continue
|
||||
|
||||
# Show errors/fatal to user
|
||||
if level in ("error", "fatal"):
|
||||
stderr.write(format_caddy_log(level, logger, msg))
|
||||
elif not level:
|
||||
# Unparseable non-empty line (e.g., sudo prompt) - pass through with prefix
|
||||
stderr.write(f"┃ {strip_caddy_verbose(msg)}\n")
|
||||
stderr.flush()
|
||||
|
||||
# Start a background thread to drain stderr and show errors
|
||||
def drain_stderr():
|
||||
# Start background task to drain stderr
|
||||
async def drain_caddy_stderr():
|
||||
while True:
|
||||
line = caddy_process.stderr.readline().decode()
|
||||
line = await proc.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
decoded = line.decode().rstrip()
|
||||
if decoded:
|
||||
try:
|
||||
log = json.loads(decoded)
|
||||
level = log.get("level", "")
|
||||
if level in ("error", "fatal", "warn"):
|
||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||
except json.JSONDecodeError:
|
||||
pass # Ignore non-JSON output after startup
|
||||
|
||||
parsed = parse_caddy_log(line)
|
||||
if not parsed:
|
||||
continue
|
||||
asyncio.create_task(drain_caddy_stderr())
|
||||
return proc
|
||||
|
||||
level, logger, msg = parsed
|
||||
|
||||
# Filter out info-level and admin messages
|
||||
if level == "info" or logger == "admin":
|
||||
continue
|
||||
async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
"""Run the development server with all components."""
|
||||
# Parse Vite endpoint
|
||||
endpoints = parse_endpoint(args.listen, DEFAULT_VITE_PORT)
|
||||
ep = endpoints[0]
|
||||
|
||||
# Show errors/warnings to user
|
||||
if level in ("error", "fatal", "warn"):
|
||||
stderr.write(format_caddy_log(level, logger, msg))
|
||||
elif not level:
|
||||
# Unparseable line - pass through with prefix
|
||||
stderr.write(f"┃ {strip_caddy_verbose(msg)}\n")
|
||||
if "uds" in ep:
|
||||
logger.warning("Unix sockets are not supported for Vite frontend")
|
||||
raise SystemExit(1)
|
||||
|
||||
drain_thread = Thread(target=drain_stderr, daemon=True)
|
||||
drain_thread.start()
|
||||
vite_host = ep["host"]
|
||||
vite_port = ep["port"]
|
||||
# Multiple endpoints means all-interfaces (:port syntax)
|
||||
if len(endpoints) > 1:
|
||||
vite_host = "0.0.0.0"
|
||||
|
||||
def cleanup():
|
||||
if caddy_process:
|
||||
caddy_process.terminate()
|
||||
caddy_process.wait()
|
||||
vite_url = f"http://localhost:{vite_port}"
|
||||
backend_url = f"http://localhost:{BACKEND_PORT}"
|
||||
|
||||
atexit.register(cleanup)
|
||||
# Compute origins for Caddy
|
||||
caddy_origins = []
|
||||
if args.auth_host:
|
||||
auth_host = args.auth_host
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
caddy_origins.append(auth_host)
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
if "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
caddy_origins.append(origin)
|
||||
if not args.auth_host and not args.origins:
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||
|
||||
return caddy_process
|
||||
# Check ports are free before starting
|
||||
await check_ports_free(vite_url, backend_url)
|
||||
|
||||
# Set environment for subprocesses
|
||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = vite_url
|
||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
||||
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else vite_url
|
||||
if args.auth_host:
|
||||
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
||||
|
||||
# Build commands
|
||||
frontend_path = Path(__file__).parent.parent / "frontend"
|
||||
vite_cmd = build_vite_cmd(vite_host, vite_port)
|
||||
install_cmd = build_npm_install_cmd()
|
||||
|
||||
paskia_cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
|
||||
paskia_cmd.extend(["--rp-id", args.rp_id])
|
||||
if args.auth_host:
|
||||
paskia_cmd.extend(["--auth-host", args.auth_host])
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
paskia_cmd.extend(["--origin", origin])
|
||||
paskia_cmd.extend(remaining)
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
# Start Caddy first if requested (needs to bind ports)
|
||||
if args.caddy:
|
||||
caddy_proc = await run_caddy(caddy_origins, vite_port)
|
||||
pg._procs.append(caddy_proc)
|
||||
pg._cmds[caddy_proc.pid] = "caddy"
|
||||
|
||||
# Run npm install concurrently with backend startup
|
||||
if install_cmd and (frontend_path / "package.json").exists():
|
||||
npm_proc = await pg.spawn(*install_cmd, cwd=str(frontend_path))
|
||||
else:
|
||||
npm_proc = None
|
||||
|
||||
# Start paskia backend
|
||||
logger.info(">>> (devmode) %s", " ".join(paskia_cmd))
|
||||
paskia_proc = await asyncio.create_subprocess_exec(*paskia_cmd)
|
||||
pg._procs.append(paskia_proc)
|
||||
pg._cmds[paskia_proc.pid] = "paskia"
|
||||
|
||||
# Wait for npm install to complete before starting Vite
|
||||
if npm_proc:
|
||||
await pg.wait(npm_proc)
|
||||
|
||||
# Start Vite dev server
|
||||
if vite_cmd:
|
||||
await pg.spawn(*vite_cmd, cwd=str(frontend_path))
|
||||
else:
|
||||
logger.info(
|
||||
"Backend expects Vite at %s - start it manually if needed", vite_url
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Parse optional hostport 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(
|
||||
@@ -387,85 +290,8 @@ def main():
|
||||
parser.add_argument("--auth-host", help="Dedicated auth host")
|
||||
args, remaining = parser.parse_known_args()
|
||||
|
||||
# Parse Vite endpoint
|
||||
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
|
||||
args.hostport, DEFAULT_VITE_PORT
|
||||
)
|
||||
|
||||
if vite_uds:
|
||||
raise SystemExit("┃ ⚠️ Unix sockets are not supported for Vite frontend")
|
||||
|
||||
# Handle all-interfaces case (:port syntax)
|
||||
# Vite uses 0.0.0.0 to listen on all interfaces (IPv4 only, sufficient for dev)
|
||||
if all_ifaces:
|
||||
vite_host = "0.0.0.0"
|
||||
|
||||
# Build Vite URL for FASTAPI_VUE_FRONTEND_URL (always use localhost for URL)
|
||||
vite_url = f"http://localhost:{vite_port}"
|
||||
|
||||
# Compute origins for Caddy (user-specified or auto-generated)
|
||||
caddy_origins = []
|
||||
if args.auth_host:
|
||||
auth_host = args.auth_host
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
caddy_origins.append(auth_host)
|
||||
# Also run on rp-id when auth-host is specified
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
if "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
caddy_origins.append(origin)
|
||||
# If neither auth-host nor origins specified, run on rp-id
|
||||
if not args.auth_host and not args.origins:
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||
|
||||
# Start Caddy if requested (after computing origins)
|
||||
if args.caddy:
|
||||
if not caddy_origins:
|
||||
caddy_origins = [f"https://{args.rp_id}"]
|
||||
stderr.write(f">>> sudo caddy @ {' '.join(caddy_origins)}\n")
|
||||
if not run_caddy(caddy_origins, vite_port):
|
||||
raise SystemExit(1)
|
||||
|
||||
# Set dev mode env vars for subprocesses (fastapi-vue convention)
|
||||
env = os.environ.copy()
|
||||
env["FASTAPI_VUE_FRONTEND_URL"] = vite_url
|
||||
env["FASTAPI_VUE_BACKEND_URL"] = f"http://localhost:{BACKEND_PORT}"
|
||||
# User-facing URL: Caddy HTTPS when running, else Vite HTTP
|
||||
if args.caddy:
|
||||
env["PASKIA_SITE_URL"] = caddy_origins[0] # auth-host or https://{rp-id}
|
||||
else:
|
||||
env["PASKIA_SITE_URL"] = vite_url
|
||||
|
||||
# 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}"]
|
||||
|
||||
# Pass through rp-id (always pass, has default)
|
||||
cmd.extend(["--rp-id", args.rp_id])
|
||||
|
||||
# Pass through auth-host if specified
|
||||
if args.auth_host:
|
||||
cmd.extend(["--auth-host", args.auth_host])
|
||||
|
||||
# Pass through origins as specified
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
cmd.extend(["--origin", origin])
|
||||
|
||||
# Add remaining args (ones we didn't parse)
|
||||
cmd.extend(remaining)
|
||||
|
||||
stderr.write(f">>> (devmode) {' '.join(cmd)}\n")
|
||||
subprocess.run(cmd, env=env)
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(run_devserver(args, remaining))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
"""Hatch build hook for building Vue frontend during package build."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
|
||||
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
"""Run a command and display it."""
|
||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||
stderr.write(f"### {' '.join(display_cmd)}\n")
|
||||
subprocess.run(cmd, check=True, **kwargs)
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
"""Build hook that compiles Vue frontend before packaging."""
|
||||
|
||||
def initialize(self, version, build_data):
|
||||
super().initialize(version, build_data)
|
||||
stderr.write(">>> Building the frontend\n")
|
||||
|
||||
install_cmd, build_cmd = find_build_tool() # noqa # type: ignore
|
||||
|
||||
try:
|
||||
run(install_cmd, cwd="frontend")
|
||||
stderr.write("\n")
|
||||
run(build_cmd, cwd="frontend")
|
||||
except Exception as e:
|
||||
stderr.write(f"Error occurred while building frontend: {e}\n")
|
||||
raise
|
||||
build("frontend")
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
"""Check if Node.js version is >= 20.
|
||||
|
||||
Raises RuntimeError if version is too old or cannot be determined.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Parse version like "v20.10.0" or "v18.17.1"
|
||||
match = re.match(r"v(\d+)", version_str)
|
||||
if match:
|
||||
major_version = int(match.group(1))
|
||||
if major_version >= 20:
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
pass
|
||||
raise RuntimeError("Could not determine Node.js version")
|
||||
|
||||
|
||||
def find_js_runtime() -> tuple[str, str]:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Raises JSRuntimeError if no suitable runtime is found.
|
||||
"""
|
||||
options = ["npm", "deno", "bun"]
|
||||
node_version_error: RuntimeError | None = None
|
||||
|
||||
# Check for JS_RUNTIME environment variable
|
||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||
js_runtime = js_runtime_env
|
||||
js_path = Path(js_runtime)
|
||||
runtime_name = js_path.name
|
||||
# Map node to npm
|
||||
if runtime_name == "node":
|
||||
runtime_name = "npm"
|
||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||
for option in options:
|
||||
if option == runtime_name or runtime_name.startswith(option):
|
||||
tool = shutil.which(js_runtime)
|
||||
if tool is None:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||
)
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||
)
|
||||
_check_node_version(node_path) # Raises on failure
|
||||
return tool, option
|
||||
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
||||
|
||||
# Auto-detect
|
||||
for option in options:
|
||||
if tool := shutil.which(option):
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
continue
|
||||
try:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError as e:
|
||||
node_version_error = e
|
||||
continue # Try next runtime
|
||||
return tool, option
|
||||
|
||||
# No runtime found - provide helpful error
|
||||
if node_version_error:
|
||||
raise node_version_error
|
||||
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
||||
|
||||
|
||||
def find_build_tool():
|
||||
"""Find JavaScript runtime and construct install/build commands.
|
||||
|
||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install = {
|
||||
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install",),
|
||||
"bun": ("--bun", "install"),
|
||||
}
|
||||
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||
build = {
|
||||
"deno": ("run", "-A", "npm:vite", "build"),
|
||||
"npm": ("run", "build"),
|
||||
"bun": ("--bun", "run", "build"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install[name]], [tool, *build[name]]
|
||||
|
||||
|
||||
def find_dev_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct dev command.
|
||||
|
||||
Returns dev_cmd (without vite-specific args).
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
|
||||
if name == "bun":
|
||||
logger.warning(
|
||||
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||
)
|
||||
|
||||
return [tool, *dev_args[name]]
|
||||
|
||||
|
||||
def find_install_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct install command.
|
||||
|
||||
Returns install_cmd.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install_args = {
|
||||
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install", "--silent"),
|
||||
"bun": ("install", "--silent"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install_args[name]]
|
||||
|
||||
|
||||
def build(folder: str = "frontend") -> None:
|
||||
"""Build the frontend in the specified folder.
|
||||
|
||||
Raises SystemExit(1) on failure.
|
||||
"""
|
||||
logger.info(">>> Building %s", folder)
|
||||
|
||||
try:
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(e)
|
||||
raise SystemExit(1)
|
||||
|
||||
def run(cmd):
|
||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder)
|
||||
|
||||
try:
|
||||
run(install_cmd)
|
||||
logger.info("")
|
||||
run(build_cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Coroutine
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
|
||||
def __init__(self):
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
|
||||
async def spawn(
|
||||
self, *cmd: str, cwd: str | None = None, env: dict | None = None
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd, env=env)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
|
||||
async def wait(
|
||||
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, *_):
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, immediate: bool = False):
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
with suppress(asyncio.CancelledError):
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
|
||||
async def check(client: httpx.AsyncClient, url: str) -> None:
|
||||
with suppress(httpx.RequestError):
|
||||
res = await client.get(url, timeout=0.1)
|
||||
server = res.headers.get("server", "server")
|
||||
logger.warning("Conflicting %s already running at %s", server, url)
|
||||
raise SystemExit(1)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await asyncio.gather(*[check(client, url) for url in urls])
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "") -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
"""
|
||||
max_attempts = 50
|
||||
full_url = f"{url}{path}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(full_url, timeout=1.0)
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def setup_vite(
|
||||
endpoint: str, default_port: int = 5173
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""Parse frontend endpoint and build commands.
|
||||
|
||||
Returns (url, install_cmd, dev_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
port = endpoints[0]["port"]
|
||||
host = endpoints[0]["host"]
|
||||
|
||||
install_cmd = find_install_tool()
|
||||
dev_cmd = find_dev_tool()
|
||||
if host != "localhost":
|
||||
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||
dev_cmd.append(f"--port={port}")
|
||||
|
||||
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||
|
||||
|
||||
def setup_fastapi(
|
||||
endpoint: str, module: str, default_port: int = 8000
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build uvicorn command.
|
||||
|
||||
Returns (url, uvicorn_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
reload_dir = module.split(".")[0] # Don't reload on frontend changes
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
module,
|
||||
f"--host={host}",
|
||||
f"--port={port}",
|
||||
"--reload",
|
||||
f"--reload-dir={reload_dir}",
|
||||
"--forwarded-allow-ips=*",
|
||||
]
|
||||
return f"http://{host}:{port}", cmd
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Shared utilities for build and dev scripts."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
|
||||
def find_js_runtime() -> tuple[str, str] | None:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Returns None if no runtime is found.
|
||||
"""
|
||||
options = ["deno", "npm", "bun"]
|
||||
|
||||
# Check for JS_RUNTIME environment variable
|
||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||
js_runtime = js_runtime_env
|
||||
js_path = Path(js_runtime)
|
||||
runtime_name = js_path.name
|
||||
# Map node to npm
|
||||
if runtime_name == "node":
|
||||
runtime_name = "npm"
|
||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||
for option in options:
|
||||
if option == runtime_name or runtime_name.startswith(option):
|
||||
tool = shutil.which(js_runtime)
|
||||
if tool is None:
|
||||
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
|
||||
return None
|
||||
return tool, option
|
||||
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
|
||||
return None
|
||||
|
||||
# Auto-detect
|
||||
for option in options:
|
||||
if tool := shutil.which(option):
|
||||
return tool, option
|
||||
return None
|
||||
|
||||
|
||||
def find_build_tool():
|
||||
"""Find JavaScript runtime and construct install/build commands.
|
||||
|
||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install = {
|
||||
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install",),
|
||||
"bun": ("--bun", "install"),
|
||||
}
|
||||
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||
build = {
|
||||
"deno": ("run", "-A", "npm:vite", "build"),
|
||||
"npm": ("run", "build"),
|
||||
"bun": ("--bun", "run", "build"),
|
||||
}
|
||||
|
||||
result = find_js_runtime()
|
||||
if result is None:
|
||||
raise RuntimeError(
|
||||
"Deno, npm or Bun is required for building but none was found"
|
||||
)
|
||||
|
||||
tool, name = result
|
||||
return [tool, *install[name]], [tool, *build[name]]
|
||||
|
||||
|
||||
def find_dev_tool():
|
||||
"""Find JavaScript runtime and construct dev command.
|
||||
|
||||
Returns (dev_cmd, tool_name) or (None, None) if not found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
result = find_js_runtime()
|
||||
if result is None:
|
||||
return None, None
|
||||
|
||||
tool, name = result
|
||||
return [tool, *dev_args[name]], name
|
||||
+3
-8
@@ -39,7 +39,6 @@ from paskia.db.operations import DB
|
||||
from paskia.fastapi.mainapp import app
|
||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util.passphrase import generate
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -181,7 +180,7 @@ async def session_token(
|
||||
return create_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
host="localhost:4401",
|
||||
host="localhost",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=expires(),
|
||||
@@ -196,7 +195,7 @@ async def regular_session_token(
|
||||
return create_session(
|
||||
user_uuid=regular_user.uuid,
|
||||
credential_uuid=regular_credential.uuid,
|
||||
host="localhost:4401",
|
||||
host="localhost",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=expires(),
|
||||
@@ -206,15 +205,11 @@ async def regular_session_token(
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def reset_token(test_db: DB, test_user: User, test_credential: Credential) -> str:
|
||||
"""Create a reset token for the test user."""
|
||||
|
||||
token = generate()
|
||||
create_reset_token(
|
||||
return create_reset_token(
|
||||
user_uuid=test_user.uuid,
|
||||
passphrase=token,
|
||||
expiry=reset_expires(),
|
||||
token_type="reset",
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
|
||||
+223
-95
@@ -101,7 +101,7 @@ async def second_org_session_token(
|
||||
return create_session(
|
||||
user_uuid=second_org_user.uuid,
|
||||
credential_uuid=second_org_credential.uuid,
|
||||
host="localhost:4401",
|
||||
host="localhost",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=expires(),
|
||||
@@ -158,7 +158,7 @@ async def org_admin_session_token(
|
||||
return create_session(
|
||||
user_uuid=org_admin_user.uuid,
|
||||
credential_uuid=org_admin_credential.uuid,
|
||||
host="localhost:4401",
|
||||
host="localhost",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=expires(),
|
||||
@@ -604,7 +604,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Admin should be able to update a role."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}",
|
||||
f"/auth/api/admin/roles/{test_role.uuid}",
|
||||
json={"display_name": "Updated Role Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -614,17 +614,19 @@ class TestAdminRoles:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_role_wrong_org(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_role
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_role,
|
||||
):
|
||||
"""Cannot update role from another org."""
|
||||
"""Org admin cannot update role from another org."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{second_org_role.uuid}",
|
||||
f"/auth/api/admin/roles/{second_org_role.uuid}",
|
||||
json={"display_name": "Try Update Wrong Org"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "Role not found" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_role_add_grantable_permission(
|
||||
@@ -637,7 +639,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Admin should be able to add grantable permissions to role."""
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/{grantable_permission.uuid}",
|
||||
f"/auth/api/admin/roles/{user_role.uuid}/permissions/{grantable_permission.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -659,7 +661,7 @@ class TestAdminRoles:
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/{perm.uuid}",
|
||||
f"/auth/api/admin/roles/{user_role.uuid}/permissions/{perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -680,14 +682,14 @@ class TestAdminRoles:
|
||||
# test_role has both auth:admin and auth:org:admin
|
||||
# Remove auth:admin first (should succeed since org:admin remains)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}/permissions/{admin_permission.uuid}",
|
||||
f"/auth/api/admin/roles/{test_role.uuid}/permissions/{admin_permission.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Now try to remove auth:org:admin (should fail - would leave no admin access)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}/permissions/{org_admin_permission.uuid}",
|
||||
f"/auth/api/admin/roles/{test_role.uuid}/permissions/{org_admin_permission.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -700,7 +702,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Admin should be able to delete a role."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}",
|
||||
f"/auth/api/admin/roles/{user_role.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -709,16 +711,18 @@ class TestAdminRoles:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_role_wrong_org(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_role
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_role,
|
||||
):
|
||||
"""Cannot delete role from another org."""
|
||||
"""Org admin cannot delete role from another org."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{second_org_role.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/roles/{second_org_role.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "Role not found" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_own_role_fails(
|
||||
@@ -726,7 +730,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Admin cannot delete their own role."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}",
|
||||
f"/auth/api/admin/roles/{test_role.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -788,7 +792,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin should be able to get user details within an org."""
|
||||
response = await client.get(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -807,7 +811,7 @@ class TestAdminUsersInOrg:
|
||||
"""Getting non-existent user should return 404."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.get(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}",
|
||||
f"/auth/api/admin/users/{fake_uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -816,16 +820,18 @@ class TestAdminUsersInOrg:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_wrong_org(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_user,
|
||||
):
|
||||
"""Getting user from another org should return 404."""
|
||||
"""Org admin cannot get user from another org."""
|
||||
response = await client.get(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "User not found" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_with_org_admin(
|
||||
@@ -837,7 +843,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Org admin should be able to get user details."""
|
||||
response = await client.get(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{org_admin_user.uuid}",
|
||||
f"/auth/api/admin/users/{org_admin_user.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -850,7 +856,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin should be able to update user display name."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": "Updated Admin Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -863,7 +869,7 @@ class TestAdminUsersInOrg:
|
||||
"""Updating non-existent user should return 404."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/display-name",
|
||||
f"/auth/api/admin/users/{fake_uuid}/display-name",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -873,15 +879,19 @@ class TestAdminUsersInOrg:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_display_name_wrong_org(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_user,
|
||||
):
|
||||
"""Updating user from another org should return 404."""
|
||||
"""Org admin cannot update user from another org."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/display-name",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_display_name_empty(
|
||||
@@ -889,7 +899,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user with empty display name should fail."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": " "},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -903,7 +913,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user with too long display name should fail."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": "x" * 100},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -923,8 +933,8 @@ class TestAdminUsersInOrg:
|
||||
"""Admin should be able to change user's role within org."""
|
||||
# Use regular_user who is in the same org but not the session owner
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{regular_user.uuid}/role",
|
||||
json={"role": user_role.display_name},
|
||||
f"/auth/api/admin/users/{regular_user.uuid}/role",
|
||||
json={"role_uuid": str(user_role.uuid)},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -935,13 +945,13 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user role without specifying role should fail."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/role",
|
||||
json={},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "role is required" in data["detail"]
|
||||
assert "role_uuid is required" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_user_not_found(
|
||||
@@ -950,27 +960,29 @@ class TestAdminUsersInOrg:
|
||||
"""Updating role for non-existent user should fail."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/role",
|
||||
json={"role": "User Role"},
|
||||
f"/auth/api/admin/users/{fake_uuid}/role",
|
||||
json={"role_uuid": str(uuid7.create())},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "User not found" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_wrong_org(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_user,
|
||||
):
|
||||
"""Updating role for user in another org should fail."""
|
||||
"""Org admin cannot update role for user in another org."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/role",
|
||||
json={"role": "User Role"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/role",
|
||||
json={"role_uuid": str(uuid7.create())},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "does not belong" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_invalid_role(
|
||||
@@ -978,8 +990,8 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user to non-existent role should fail."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role",
|
||||
json={"role": "Nonexistent Role"},
|
||||
f"/auth/api/admin/users/{test_user.uuid}/role",
|
||||
json={"role_uuid": str(uuid7.create())},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -997,8 +1009,8 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin cannot change their own role to non-admin role."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{org_admin_user.uuid}/role",
|
||||
json={"role": user_role.display_name},
|
||||
f"/auth/api/admin/users/{org_admin_user.uuid}/role",
|
||||
json={"role_uuid": str(user_role.uuid)},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1018,8 +1030,8 @@ class TestAdminUsersInOrg:
|
||||
# test_user is already on test_role which has auth:admin
|
||||
# Changing to the same role should succeed (no permission loss)
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role",
|
||||
json={"role": test_role.display_name},
|
||||
f"/auth/api/admin/users/{test_user.uuid}/role",
|
||||
json={"role_uuid": str(test_role.uuid)},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1030,7 +1042,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin should be able to create reset links for users."""
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/create-link",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/create-link",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1045,7 +1057,7 @@ class TestAdminUsersInOrg:
|
||||
"""Creating reset link for non-existent user should fail."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/create-link",
|
||||
f"/auth/api/admin/users/{fake_uuid}/create-link",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1054,16 +1066,18 @@ class TestAdminUsersInOrg:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_reset_link_wrong_org(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_user,
|
||||
):
|
||||
"""Creating reset link for user in another org should fail."""
|
||||
"""Org admin cannot create reset link for user in another org."""
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/create-link",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/create-link",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "not found in organization" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_registration_link_without_credentials(
|
||||
@@ -1083,7 +1097,7 @@ class TestAdminUsersInOrg:
|
||||
create_user(user_no_cred)
|
||||
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{user_no_cred.uuid}/create-link",
|
||||
f"/auth/api/admin/users/{user_no_cred.uuid}/create-link",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1091,6 +1105,120 @@ class TestAdminUsersInOrg:
|
||||
assert "url" in data
|
||||
|
||||
|
||||
# -------------------- User Deletion Tests --------------------
|
||||
|
||||
|
||||
class TestAdminUserDeletion:
|
||||
"""Tests for admin user deletion"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_success(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_org,
|
||||
user_role,
|
||||
test_db: DB,
|
||||
):
|
||||
"""Admin should be able to delete a user."""
|
||||
# Create a user to delete
|
||||
user_to_delete = User.create(
|
||||
display_name="User To Delete",
|
||||
role=user_role.uuid,
|
||||
)
|
||||
create_user(user_to_delete)
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{user_to_delete.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
# Verify user is actually deleted
|
||||
assert user_to_delete.uuid not in db.data().users
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_not_found(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""Deleting non-existent user should return 404."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{fake_uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "User not found" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_own_user_fails(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_user
|
||||
):
|
||||
"""Admin cannot delete their own account."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{test_user.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Cannot delete your own account" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_wrong_org(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
org_admin_session_token: str,
|
||||
second_org_user,
|
||||
):
|
||||
"""Org admin cannot delete user from another org."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_org_admin_success(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
user_role,
|
||||
test_db: DB,
|
||||
):
|
||||
"""Org admin should be able to delete users in their org."""
|
||||
# Create a user in the same org to delete
|
||||
user_to_delete = User.create(
|
||||
display_name="Org User To Delete",
|
||||
role=user_role.uuid,
|
||||
)
|
||||
create_user(user_to_delete)
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{user_to_delete.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user_regular_user_forbidden(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
regular_session_token: str,
|
||||
test_user,
|
||||
):
|
||||
"""Regular user trying to delete user should get 403."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{test_user.uuid}",
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# -------------------- Credential Tests --------------------
|
||||
|
||||
|
||||
@@ -1108,7 +1236,7 @@ class TestAdminCredentials:
|
||||
):
|
||||
"""Admin should be able to delete a user's credential."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/credentials/{test_credential.uuid}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/credentials/{test_credential.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1123,7 +1251,7 @@ class TestAdminCredentials:
|
||||
fake_user_uuid = uuid7.create()
|
||||
fake_cred_uuid = uuid7.create()
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_user_uuid}/credentials/{fake_cred_uuid}",
|
||||
f"/auth/api/admin/users/{fake_user_uuid}/credentials/{fake_cred_uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1134,17 +1262,17 @@ class TestAdminCredentials:
|
||||
async def test_delete_credential_wrong_org(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_user,
|
||||
second_org_credential,
|
||||
):
|
||||
"""Deleting credential for user in another org should fail."""
|
||||
"""Org admin cannot delete credential for user in another org."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/credentials/{second_org_credential.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/credentials/{second_org_credential.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# -------------------- Session Tests --------------------
|
||||
@@ -1175,7 +1303,7 @@ class TestAdminSessions:
|
||||
)
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{extra_token}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_token}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1193,7 +1321,7 @@ class TestAdminSessions:
|
||||
):
|
||||
"""Admin can delete their own current session."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{session_token}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_token}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1207,7 +1335,7 @@ class TestAdminSessions:
|
||||
"""Deleting session for non-existent user should fail."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/sessions/fake-session-id",
|
||||
f"/auth/api/admin/users/{fake_uuid}/sessions/fake-session-id",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1218,16 +1346,16 @@ class TestAdminSessions:
|
||||
async def test_delete_session_wrong_org(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_user,
|
||||
):
|
||||
"""Deleting session for user in another org should fail."""
|
||||
"""Org admin cannot delete session for user in another org."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/sessions/fake-session",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/sessions/fake-session",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session_invalid_id(
|
||||
@@ -1235,7 +1363,7 @@ class TestAdminSessions:
|
||||
):
|
||||
"""Deleting session with invalid/non-existent ID should fail."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/invalid!!id",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/invalid!!id",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1250,7 +1378,7 @@ class TestAdminSessions:
|
||||
# Use a valid format but non-existent key
|
||||
fake_token = secrets.token_urlsafe(12)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{fake_token}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{fake_token}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1550,7 +1678,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user (not org admin) trying to create reset link should get 403."""
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/create-link",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/create-link",
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1565,7 +1693,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to get user details should get 403."""
|
||||
response = await client.get(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}",
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1580,7 +1708,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to update display name should get 403."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -1597,7 +1725,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to delete credential should get 403."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/credentials/{test_credential.uuid}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/credentials/{test_credential.uuid}",
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1612,7 +1740,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to delete session should get 403."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/some-session",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/some-session",
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
+1
-1
@@ -526,7 +526,7 @@ class TestValidateSessionRefresh:
|
||||
token = create_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
host="localhost:4401",
|
||||
host="localhost",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
expiry=old_expiry,
|
||||
|
||||
Reference in New Issue
Block a user