Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ebe5ae968 | ||
|
|
a944224027 | ||
|
|
aef0e0cb44 | ||
|
|
d41cffc03e | ||
|
|
bad709a3ab | ||
|
|
1cfde06de9 | ||
|
|
b0b36e88b1 | ||
|
|
2237e6b5e9 | ||
|
|
c2ea01e6d9 | ||
|
|
8b6bdd0f9c | ||
|
|
3ca784dc3c | ||
|
|
d8876d9202 | ||
|
|
aa22b7709f | ||
|
|
a8222f4bba | ||
|
|
16ab111a89 | ||
|
|
d826146932 | ||
|
|
fda9b2545e | ||
|
|
0cf551cb28 |
@@ -87,10 +87,10 @@ This starts the server on [localhost:4401](http://localhost:4401) with passkeys
|
|||||||
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
paskia --rp-id example.com --rp-name "Example Corp" --save
|
paskia --rp-id=example.com --rp-name="Example Corp" --save
|
||||||
```
|
```
|
||||||
|
|
||||||
This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration. The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`.
|
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`, of which we will make use of with the systemd config later on.
|
||||||
|
|
||||||
### Step 3: Set Up Caddy
|
### Step 3: Set Up Caddy
|
||||||
|
|
||||||
@@ -177,20 +177,20 @@ Create a system user paskia, install UV on the system, and create a systemd unit
|
|||||||
```fish
|
```fish
|
||||||
sudo useradd --system --home-dir /srv/paskia --create-home paskia
|
sudo useradd --system --home-dir /srv/paskia --create-home paskia
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
|
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
sudo systemctl edit --force --full paskia.service
|
sudo systemctl edit --force --full paskia@.service
|
||||||
```
|
```
|
||||||
|
|
||||||
Paste the following and save:
|
Paste the following and save:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Paskia Authentication Server
|
Description=Paskia for %i
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=paskia
|
User=paskia
|
||||||
WorkingDirectory=/srv/paskia
|
WorkingDirectory=/srv/paskia
|
||||||
ExecStart=uvx paskia --rp-id=example.com
|
ExecStart=uvx paskia --rp-id=%i
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -199,9 +199,30 @@ WantedBy=multi-user.target
|
|||||||
Then enable and start, view output for registration link:
|
Then enable and start, view output for registration link:
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
sudo systemctl enable --now paskia && sudo journalctl -u paskia -f -n 20 -o cat
|
sudo systemctl enable --now paskia@example.com && sudo journalctl -u paskia@example.com -f -n 30 -o cat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Optional: Dedicated Authentication Site
|
||||||
|
|
||||||
|
By default, Paskia serves login dialogs and admin interface at the `/auth/` path on each protected site. For a cleaner setup, you can use a dedicated authentication subdomain instead. We assume you have your DNS setup for that domain or a wildcard of all subdomains to current machine.
|
||||||
|
|
||||||
|
Configure Paskia with the authentication host:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
paskia --rp-id example.com --auth-host=auth.example.com --save
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a Caddy configuration for the authentication domain:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
auth.example.com {
|
||||||
|
reverse_proxy :4401
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. No other changes are needed. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Further Documentation
|
## Further Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,3 @@ onUnmounted(() => {
|
|||||||
removeAuthIframe()
|
removeAuthIframe()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -841,7 +841,6 @@ async function submitDialog() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-admin { padding-bottom: var(--space-3xl); }
|
.view-admin { padding-bottom: var(--space-3xl); }
|
||||||
.view-header { display: flex; flex-direction: column; gap: var(--space-sm); }
|
|
||||||
.admin-section { margin-top: var(--space-xl); }
|
.admin-section { margin-top: var(--space-xl); }
|
||||||
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||||
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<div v-if="status.show" class="global-status" style="display: block;">
|
<div v-if="status.show" class="global-status show">
|
||||||
<div :class="['status', status.type]">
|
<div :class="['status', status.type]">
|
||||||
{{ status.message }}
|
{{ status.message }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="view-root">
|
<main class="view-root">
|
||||||
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
|
<div class="surface surface--tight reset-container">
|
||||||
<header class="view-header" style="text-align: center;">
|
<header class="view-header reset-header">
|
||||||
<h1>🔑 Registration</h1>
|
<h1>🔑 Registration</h1>
|
||||||
<p class="view-lede">
|
<p class="view-lede">
|
||||||
{{ subtitleMessage }}
|
{{ subtitleMessage }}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<section class="section-block" v-else-if="!canRegister">
|
<section class="section-block" v-else-if="!canRegister">
|
||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<div class="button-row center" style="justify-content: center;">
|
<div class="button-row button-row--center">
|
||||||
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,13 +203,14 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.center {
|
.reset-container {
|
||||||
text-align: center;
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-row.center {
|
.reset-header {
|
||||||
display: flex;
|
text-align: center;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-body {
|
.section-body {
|
||||||
|
|||||||
@@ -110,8 +110,5 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.error { color: var(--color-danger-text); }
|
|
||||||
.small { font-size: 0.9rem; }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -381,25 +381,15 @@ defineExpose({ focusFirstElement })
|
|||||||
.card.surface { padding: var(--space-lg); }
|
.card.surface { padding: var(--space-lg); }
|
||||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
||||||
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
|
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
|
||||||
.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-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
|
|
||||||
.matrix-scroll { overflow-x: auto; }
|
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
||||||
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
|
|
||||||
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
|
|
||||||
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
|
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
|
||||||
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||||
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
||||||
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
|
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
|
||||||
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
|
.role-column { flex: 1; min-width: 200px; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-md); }
|
||||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
||||||
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
||||||
.role-actions { display: flex; gap: var(--space-xs); }
|
.role-actions { display: flex; gap: var(--space-xs); }
|
||||||
.plus-btn { background: var(--color-accent-soft); color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
|
.plus-btn { background: none; color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
|
||||||
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
||||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
|
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
|
||||||
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
||||||
@@ -407,9 +397,6 @@ defineExpose({ focusFirstElement })
|
|||||||
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
|
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||||
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
|
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
|
||||||
.empty-text { margin: 0; }
|
.empty-text { margin: 0; }
|
||||||
.delete-icon { color: var(--color-danger); }
|
|
||||||
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.roles-grid { flex-direction: column; }
|
.roles-grid { flex-direction: column; }
|
||||||
|
|||||||
@@ -353,8 +353,6 @@ defineExpose({ focusFirstElement })
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.permissions-section { margin-bottom: var(--space-xl); }
|
.permissions-section { margin-bottom: var(--space-xl); }
|
||||||
.permissions-section h2 { margin-bottom: var(--space-md); }
|
.permissions-section h2 { margin-bottom: var(--space-md); }
|
||||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
|
||||||
.actions button { width: auto; }
|
|
||||||
.org-table a { text-decoration: none; color: var(--color-link); }
|
.org-table a { text-decoration: none; color: var(--color-link); }
|
||||||
.org-table a:hover { text-decoration: underline; }
|
.org-table a:hover { text-decoration: underline; }
|
||||||
.org-table .center { width: 6rem; min-width: 6rem; }
|
.org-table .center { width: 6rem; min-width: 6rem; }
|
||||||
@@ -363,24 +361,10 @@ defineExpose({ focusFirstElement })
|
|||||||
.perm-title { font-weight: 600; color: var(--color-heading); }
|
.perm-title { font-weight: 600; color: var(--color-heading); }
|
||||||
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
|
.perm-domain { color: var(--color-text-muted); font-size: 0.9rem; }
|
||||||
.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); }
|
|
||||||
.delete-icon { color: var(--color-danger); }
|
|
||||||
.delete-icon:hover { background: var(--color-danger-bg); color: var(--color-danger-text); }
|
|
||||||
.matrix-wrapper { margin: var(--space-md) 0; padding: var(--space-lg); }
|
|
||||||
.matrix-scroll { overflow-x: auto; }
|
|
||||||
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
|
|
||||||
.perm-matrix-grid { display: inline-grid; gap: 0.25rem; align-items: stretch; }
|
|
||||||
.perm-matrix-grid > * { padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .grid-head { color: var(--color-text-muted); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; }
|
|
||||||
.perm-matrix-grid .perm-head { display: flex; align-items: flex-end; justify-content: flex-start; padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
|
.perm-matrix-grid .org-head { display: flex; align-items: flex-end; justify-content: center; }
|
||||||
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
.perm-matrix-grid .org-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||||
.perm-name { font-weight: 600; color: var(--color-heading); padding: 0.35rem 0.45rem; font-size: 0.75rem; }
|
|
||||||
.display-text { margin-right: var(--space-xs); }
|
.display-text { margin-right: var(--space-xs); }
|
||||||
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
|
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
|
||||||
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
|
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
|
||||||
.perm-actions { text-align: center; }
|
.perm-actions { text-align: center; }
|
||||||
.center { text-align: center; }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -256,11 +256,5 @@ defineExpose({ focusFirstElement })
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
|
||||||
.admin-actions { display: flex; gap: 0.5rem; }
|
.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; }
|
.ancillary-actions { margin-top: -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); }
|
|
||||||
.error { color: var(--color-danger-text); }
|
|
||||||
.small { font-size: 0.9rem; }
|
|
||||||
.muted { color: var(--color-text-muted); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+181
-12
@@ -9,8 +9,9 @@
|
|||||||
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
--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;
|
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
--color-canvas: white;
|
--color-canvas: white;
|
||||||
--color-surface: oklch(0.97 0.005 var(--hue));
|
--color-surface: oklch(0.95 0.03 var(--hue));
|
||||||
--color-surface-subtle: oklch(0.94 0.01 var(--hue));
|
--color-surface-subtle: oklch(0.9 0.03 var(--hue));
|
||||||
|
--color-surface-hover: white;
|
||||||
--color-dialog: white;
|
--color-dialog: white;
|
||||||
--color-border: oklch(0.8 0.02 var(--hue));
|
--color-border: oklch(0.8 0.02 var(--hue));
|
||||||
--color-border-strong: oklch(0.55 0.15 var(--hue));
|
--color-border-strong: oklch(0.55 0.15 var(--hue));
|
||||||
@@ -31,30 +32,35 @@
|
|||||||
--color-info-text: oklch(0.45 0.15 var(--hue));
|
--color-info-text: oklch(0.45 0.15 var(--hue));
|
||||||
--color-info-bg: oklch(0.95 0.02 var(--hue));
|
--color-info-bg: oklch(0.95 0.02 var(--hue));
|
||||||
--color-danger: oklch(0.55 0.22 0.07turn);
|
--color-danger: oklch(0.55 0.22 0.07turn);
|
||||||
|
--color-primary: oklch(0.55 0.2 var(--hue));
|
||||||
|
--color-error: oklch(0.45 0.2 0.07turn);
|
||||||
|
--color-success: oklch(0.4 0.15 0.4turn);
|
||||||
|
--color-bg: oklch(0.97 0.005 var(--hue));
|
||||||
|
--color-accent-soft: oklch(0.95 0.08 var(--hue));
|
||||||
--shadow-soft: 0 0 .2rem black;
|
--shadow-soft: 0 0 .2rem black;
|
||||||
--radius-none: 0;
|
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 6px;
|
--radius-md: 6px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
--space-xxs: 0.25rem;
|
|
||||||
--space-xs: 0.5rem;
|
--space-xs: 0.5rem;
|
||||||
--space-sm: 0.75rem;
|
--space-sm: 0.75rem;
|
||||||
--space-md: 1rem;
|
--space-md: 1rem;
|
||||||
--space-lg: 1.5rem;
|
--space-lg: 1.5rem;
|
||||||
--space-xl: 2.25rem;
|
--space-xl: 2.25rem;
|
||||||
--space-xxl: 3.5rem;
|
--space-3xl: 5rem;
|
||||||
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
--layout-padding: clamp(1.5rem, 3vw + 1rem, 3.25rem);
|
||||||
--transition-base: 160ms ease;
|
--transition-base: 160ms ease;
|
||||||
--focus-ring: 0 0 0 2px var(--color-accent);
|
--focus-ring: 0 0 0 2px var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--color-canvas: oklch(0.15 0.03 var(--hue));
|
--color-canvas: oklch(0.17 0.05 var(--hue));
|
||||||
--color-surface: oklch(0.18 0.03 var(--hue));
|
--color-surface: oklch(0.22 0.05 var(--hue));
|
||||||
--color-surface-subtle: oklch(0.22 0.03 var(--hue));
|
--color-surface-subtle: oklch(0.25 0.05 var(--hue));
|
||||||
--color-dialog: oklch(0.22 0.03 var(--hue));
|
--color-surface-hover: oklch(0.28 0.05 var(--hue));
|
||||||
--color-border: oklch(0.3 0.03 var(--hue));
|
--color-dialog: oklch(0.22 0.05 var(--hue));
|
||||||
--color-border-strong: oklch(0.4 0.04 var(--hue));
|
--color-border: oklch(0.3 0.05 var(--hue));
|
||||||
|
--color-border-strong: oklch(0.4 0.05 var(--hue));
|
||||||
--color-heading: white;
|
--color-heading: white;
|
||||||
--color-text: oklch(0.9 0.01 var(--hue));
|
--color-text: oklch(0.9 0.01 var(--hue));
|
||||||
--color-text-muted: oklch(0.7 0.02 var(--hue));
|
--color-text-muted: oklch(0.7 0.02 var(--hue));
|
||||||
@@ -72,7 +78,13 @@
|
|||||||
--color-info-text: oklch(0.8 0.1 var(--hue));
|
--color-info-text: oklch(0.8 0.1 var(--hue));
|
||||||
--color-info-bg: oklch(0.3 0.05 var(--hue));
|
--color-info-bg: oklch(0.3 0.05 var(--hue));
|
||||||
--color-danger: oklch(0.7 0.18 0.07turn);
|
--color-danger: oklch(0.7 0.18 0.07turn);
|
||||||
|
--color-primary: oklch(0.7 0.15 var(--hue));
|
||||||
|
--color-error: oklch(0.8 0.12 0.07turn);
|
||||||
|
--color-success: oklch(0.75 0.15 0.4turn);
|
||||||
|
--color-bg: oklch(0.18 0.03 var(--hue));
|
||||||
|
--color-accent-soft: oklch(0.25 0.08 var(--hue));
|
||||||
--shadow-soft: 0 0 0 black;
|
--shadow-soft: 0 0 0 black;
|
||||||
|
--shadow-xl: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -160,6 +172,25 @@ a:focus-visible {
|
|||||||
max-width: 540px;
|
max-width: 540px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.view-root--profile .view-header-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(1200px - 2 * var(--layout-padding));
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-root--profile .theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-block--constrained {
|
||||||
|
width: 100%;
|
||||||
|
max-width: calc(1200px - 2 * var(--layout-padding));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.view-header {
|
.view-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -207,6 +238,7 @@ a:focus-visible {
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-row button {
|
.button-row button {
|
||||||
@@ -340,10 +372,118 @@ th {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Permission matrix styles */
|
||||||
|
.matrix-wrapper {
|
||||||
|
margin: var(--space-md) 0;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid {
|
||||||
|
display: inline-grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid > * {
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .grid-head {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .perm-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .rotated-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-matrix-grid .rotated-head span {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-heading);
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.center {
|
.center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon:hover {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row--center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -369,6 +509,10 @@ th {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-status.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.global-status .status {
|
.global-status .status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -742,7 +886,7 @@ th {
|
|||||||
|
|
||||||
.slot-machine {
|
.slot-machine {
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
background: var(--color-surface-hover);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
@@ -763,3 +907,28 @@ th {
|
|||||||
height: 1.8em;
|
height: 1.8em;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Spinner utilities */
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner--sm {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner--md {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root host-view" data-view="host-profile">
|
<section class="view-root view-root--wide host-view" data-view="host-profile">
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<h1>{{ headingTitle }}</h1>
|
<h1>{{ headingTitle }}</h1>
|
||||||
<p class="view-lede">{{ subheading }}</p>
|
<p class="view-lede">{{ subheading }}</p>
|
||||||
@@ -125,12 +125,3 @@ const handleButtonRowKeydown = (event) => {
|
|||||||
// Down does nothing (no elements below to navigate to)
|
// Down does nothing (no elements below to navigate to)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.host-view { padding: 3rem 1.5rem 4rem; }
|
|
||||||
.host-actions { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
||||||
.host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; }
|
|
||||||
.host-actions .button-row button { flex: 1 1 0; }
|
|
||||||
.note { margin: 0; color: var(--color-text-muted); }
|
|
||||||
.empty-state { margin: 0; color: var(--color-text-muted); }
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -27,17 +27,12 @@ defineProps({
|
|||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 4px solid var(--color-border);
|
border: 3px solid var(--color-border);
|
||||||
border-top: 4px solid var(--color-primary);
|
border-top-color: var(--color-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container p {
|
.loading-container p {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -84,12 +84,4 @@ function handleCancel() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
color: var(--color-danger-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.small {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="view-root" data-view="profile">
|
<section class="view-root view-root--profile" data-view="profile">
|
||||||
<div class="theme-toggle">
|
<div class="view-header-wrapper">
|
||||||
<ThemeSelector />
|
<div class="theme-toggle">
|
||||||
|
<ThemeSelector />
|
||||||
|
</div>
|
||||||
|
<header class="view-header">
|
||||||
|
<h1>User Profile</h1>
|
||||||
|
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||||
|
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
||||||
|
</header>
|
||||||
</div>
|
</div>
|
||||||
<header class="view-header">
|
|
||||||
<h1>User Profile</h1>
|
|
||||||
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
|
||||||
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="section-block" ref="userInfoSection">
|
<section class="section-block section-block--constrained" ref="userInfoSection">
|
||||||
<UserBasicInfo
|
<UserBasicInfo
|
||||||
v-if="authStore.userInfo?.ctx"
|
v-if="authStore.userInfo?.ctx"
|
||||||
ref="userBasicInfo"
|
ref="userBasicInfo"
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
</UserBasicInfo>
|
</UserBasicInfo>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section-block">
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Your Passkeys</h2>
|
<h2>Your Passkeys</h2>
|
||||||
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
||||||
@@ -70,6 +72,7 @@
|
|||||||
:terminating-sessions="terminatingSessions"
|
:terminating-sessions="terminatingSessions"
|
||||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
|
:section-class="useWideLayout ? '' : 'section-block--constrained'"
|
||||||
@terminate="terminateSession"
|
@terminate="terminateSession"
|
||||||
@session-hover="hoveredSession = $event"
|
@session-hover="hoveredSession = $event"
|
||||||
@navigate-out="handleSessionNavigateOut"
|
@navigate-out="handleSessionNavigateOut"
|
||||||
@@ -88,7 +91,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<section class="section-block">
|
<section :class="['section-block', { 'section-block--constrained': !useWideLayout }]">
|
||||||
<div class="button-row" ref="logoutButtons">
|
<div class="button-row" ref="logoutButtons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -333,6 +336,8 @@ const isAdmin = computed(() => {
|
|||||||
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||||
})
|
})
|
||||||
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
||||||
|
const credentials = computed(() => authStore.userInfo?.credentials || [])
|
||||||
|
const useWideLayout = computed(() => sessions.value.length > 4 || credentials.value.length > 4)
|
||||||
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
|
||||||
|
|
||||||
const saveName = async () => {
|
const saveName = async () => {
|
||||||
@@ -350,7 +355,6 @@ const saveName = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; }
|
|
||||||
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
.section-header { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
.empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; }
|
||||||
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
|
||||||
|
|||||||
@@ -55,15 +55,14 @@
|
|||||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
<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>
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
|
|
||||||
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
<div class="button-row device-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="deny"
|
@click="deny"
|
||||||
style="flex: 1;"
|
|
||||||
>
|
>
|
||||||
Deny
|
Deny
|
||||||
</button>
|
</button>
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
style="flex: 1;"
|
|
||||||
>
|
>
|
||||||
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
{{ loading ? 'Authenticating…' : 'Authorize' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -921,10 +919,6 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-info {
|
.device-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -945,9 +939,18 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
margin: 0;
|
margin: 0.5rem 0 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-error, #ef4444);
|
color: var(--color-error, #ef4444);
|
||||||
margin-bottom: 1rem;
|
}
|
||||||
|
|
||||||
|
.device-actions {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-actions button {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<div v-else-if="error" class="error-section">
|
<div v-else-if="error" class="error-section">
|
||||||
<p class="error-message">{{ error }}</p>
|
<p class="error-message">{{ error }}</p>
|
||||||
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
|
<button class="btn-primary" @click="retry">Try Again</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connecting phase -->
|
<!-- Connecting phase -->
|
||||||
@@ -293,10 +293,6 @@ defineExpose({ retry, cancel })
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-display {
|
.auth-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -499,6 +495,10 @@ defineExpose({ retry, cancel })
|
|||||||
color: var(--color-error, #ef4444);
|
color: var(--color-error, #ef4444);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-section button {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.auth-content {
|
.auth-content {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<div v-if="status.show" class="global-status" style="display: block;">
|
<div v-if="status.show" class="global-status show">
|
||||||
<div :class="['status', status.type]">
|
<div :class="['status', status.type]">
|
||||||
{{ status.message }}
|
{{ status.message }}
|
||||||
</div>
|
</div>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="section-body center">
|
<div class="section-body center">
|
||||||
<!-- Local passkey authentication view -->
|
<!-- Local passkey authentication view -->
|
||||||
<div v-if="authView === 'local'" class="auth-view">
|
<div v-if="authView === 'local'" class="auth-view">
|
||||||
<div class="button-row center" ref="buttonRow">
|
<div class="button-row button-row--center" ref="buttonRow">
|
||||||
<slot name="actions"
|
<slot name="actions"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:can-authenticate="canAuthenticate"
|
:can-authenticate="canAuthenticate"
|
||||||
@@ -284,7 +284,6 @@ defineExpose({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
|
|
||||||
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
|
||||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||||
.surface.surface--tight {
|
.surface.surface--tight {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="section-block" data-component="session-list-section">
|
<section :class="['section-block', sectionClass]" data-component="session-list-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Active Sessions</h2>
|
<h2>Active Sessions</h2>
|
||||||
<p class="section-description">{{ sectionDescription }}</p>
|
<p class="section-description">{{ sectionDescription }}</p>
|
||||||
@@ -75,6 +75,7 @@ const props = defineProps({
|
|||||||
terminatingSessions: { type: Object, default: () => ({}) },
|
terminatingSessions: { type: Object, default: () => ({}) },
|
||||||
hoveredCredentialUuid: { type: String, default: null },
|
hoveredCredentialUuid: { type: String, default: null },
|
||||||
navigationDisabled: { type: Boolean, default: false },
|
navigationDisabled: { type: Boolean, default: false },
|
||||||
|
sectionClass: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
|
const emit = defineEmits(['terminate', 'sessionHover', 'navigate-out'])
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="authStore.status.show" class="global-status" style="display: block;">
|
<div v-if="authStore.status.show" class="global-status show">
|
||||||
<div :class="['status', authStore.status.type]">
|
<div :class="['status', authStore.status.type]">
|
||||||
{{ authStore.status.message }}
|
{{ authStore.status.message }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
||||||
|
from paskia.db.structs import ResetToken
|
||||||
from paskia.util import hostutil
|
from paskia.util import hostutil
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -33,7 +34,7 @@ def reset_expires() -> datetime:
|
|||||||
def get_reset(token: str) -> "ResetToken":
|
def get_reset(token: str) -> "ResetToken":
|
||||||
"""Validate a credential reset token."""
|
"""Validate a credential reset token."""
|
||||||
|
|
||||||
record = db.get_reset_token(token)
|
record = ResetToken.by_passphrase(token)
|
||||||
if record:
|
if record:
|
||||||
return record
|
return record
|
||||||
raise ValueError("This authentication link is no longer valid.")
|
raise ValueError("This authentication link is no longer valid.")
|
||||||
|
|||||||
+25
-11
@@ -10,6 +10,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from paskia import authsession, db, globals
|
from paskia import authsession, db, globals
|
||||||
|
from paskia.db.structs import Config
|
||||||
from paskia.util import hostutil
|
from paskia.util import hostutil
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,15 +31,18 @@ def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
|||||||
return reset_link
|
return reset_link
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_system() -> None:
|
async def bootstrap_system(config: Config | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
Bootstrap the entire system with default data.
|
Bootstrap the entire system with default data.
|
||||||
|
|
||||||
Uses db.bootstrap() which performs all operations in a single transaction.
|
Uses db.bootstrap() which performs all operations in a single transaction.
|
||||||
The transaction log will show a single "bootstrap" action with all changes.
|
The transaction log will show a single "bootstrap" action with all changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration to store (rp_id, rp_name, origins, etc.)
|
||||||
"""
|
"""
|
||||||
# Call the single-transaction bootstrap function
|
# Call the single-transaction bootstrap function
|
||||||
reset_passphrase = db.bootstrap()
|
reset_passphrase = db.bootstrap(config=config)
|
||||||
|
|
||||||
# Log the reset link (this is separate from the transaction log)
|
# Log the reset link (this is separate from the transaction log)
|
||||||
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||||
@@ -52,17 +56,24 @@ async def check_admin_credentials() -> bool:
|
|||||||
bool: True if a reset link was created, False if admin already has credentials
|
bool: True if a reset link was created, False if admin already has credentials
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get permission organizations to find admin users
|
# Find the auth:admin permission
|
||||||
p = next(
|
p = next(
|
||||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
||||||
)
|
)
|
||||||
if not p or not p.orgs:
|
if not p:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get users from the first organization with admin permission
|
perm_uuid = p.uuid
|
||||||
first_org_uuid = next(iter(p.orgs))
|
|
||||||
org_users = db.get_organization_users(first_org_uuid)
|
# Find all roles that have the auth:admin permission
|
||||||
admin_users = [user for user, role in org_users if role == "Administration"]
|
admin_roles = [
|
||||||
|
r for r in db.data().roles.values() if perm_uuid in r.permissions
|
||||||
|
]
|
||||||
|
|
||||||
|
# Collect all users from those roles
|
||||||
|
admin_users = []
|
||||||
|
for role in admin_roles:
|
||||||
|
admin_users.extend(role.users)
|
||||||
|
|
||||||
if not admin_users:
|
if not admin_users:
|
||||||
return False
|
return False
|
||||||
@@ -70,7 +81,7 @@ async def check_admin_credentials() -> bool:
|
|||||||
# Check first admin user for credentials
|
# Check first admin user for credentials
|
||||||
admin_user = admin_users[0]
|
admin_user = admin_users[0]
|
||||||
|
|
||||||
if not db.get_user_credential_ids(admin_user.uuid):
|
if not admin_user.credential_ids:
|
||||||
# Admin exists but has no credentials, create reset link
|
# Admin exists but has no credentials, create reset link
|
||||||
logger.info("⚠️ Admin user has no credentials!")
|
logger.info("⚠️ Admin user has no credentials!")
|
||||||
|
|
||||||
@@ -89,10 +100,13 @@ async def check_admin_credentials() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_if_needed() -> bool:
|
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if system needs bootstrapping and perform it if necessary.
|
Check if system needs bootstrapping and perform it if necessary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if bootstrapping was performed, False if system was already set up
|
bool: True if bootstrapping was performed, False if system was already set up
|
||||||
"""
|
"""
|
||||||
@@ -105,7 +119,7 @@ async def bootstrap_if_needed() -> bool:
|
|||||||
|
|
||||||
# No admin permission found, need to bootstrap
|
# No admin permission found, need to bootstrap
|
||||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
||||||
await bootstrap_system()
|
await bootstrap_system(config=config)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
-15
@@ -26,11 +26,11 @@ from paskia.db.background import (
|
|||||||
stop_background,
|
stop_background,
|
||||||
stop_cleanup,
|
stop_cleanup,
|
||||||
)
|
)
|
||||||
|
from paskia.db.bootstrap import bootstrap
|
||||||
|
from paskia.db.lifecycle import cleanup_expired, init
|
||||||
from paskia.db.operations import (
|
from paskia.db.operations import (
|
||||||
add_permission_to_org,
|
add_permission_to_org,
|
||||||
add_permission_to_role,
|
add_permission_to_role,
|
||||||
bootstrap,
|
|
||||||
cleanup_expired,
|
|
||||||
create_credential,
|
create_credential,
|
||||||
create_credential_session,
|
create_credential_session,
|
||||||
create_org,
|
create_org,
|
||||||
@@ -47,17 +47,11 @@ from paskia.db.operations import (
|
|||||||
delete_session,
|
delete_session,
|
||||||
delete_sessions_for_user,
|
delete_sessions_for_user,
|
||||||
delete_user,
|
delete_user,
|
||||||
get_config,
|
|
||||||
get_organization_users,
|
|
||||||
get_reset_token,
|
|
||||||
get_user_credential_ids,
|
|
||||||
get_user_organization,
|
|
||||||
init,
|
|
||||||
login,
|
login,
|
||||||
remove_permission_from_org,
|
remove_permission_from_org,
|
||||||
remove_permission_from_role,
|
remove_permission_from_role,
|
||||||
set_config,
|
|
||||||
set_session_host,
|
set_session_host,
|
||||||
|
update_config,
|
||||||
update_credential_sign_count,
|
update_credential_sign_count,
|
||||||
update_org_name,
|
update_org_name,
|
||||||
update_permission,
|
update_permission,
|
||||||
@@ -69,6 +63,7 @@ from paskia.db.operations import (
|
|||||||
)
|
)
|
||||||
from paskia.db.structs import (
|
from paskia.db.structs import (
|
||||||
DB,
|
DB,
|
||||||
|
Config,
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
@@ -87,6 +82,7 @@ def data() -> DB:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Types
|
# Types
|
||||||
|
"Config",
|
||||||
"Credential",
|
"Credential",
|
||||||
"DB",
|
"DB",
|
||||||
"Org",
|
"Org",
|
||||||
@@ -112,11 +108,6 @@ __all__ = [
|
|||||||
"build_session",
|
"build_session",
|
||||||
"build_user",
|
"build_user",
|
||||||
# Read ops
|
# Read ops
|
||||||
"get_config",
|
|
||||||
"get_organization_users",
|
|
||||||
"get_reset_token",
|
|
||||||
"get_user_credential_ids",
|
|
||||||
"get_user_organization",
|
|
||||||
# Write ops
|
# Write ops
|
||||||
"add_permission_to_org",
|
"add_permission_to_org",
|
||||||
"add_permission_to_role",
|
"add_permission_to_role",
|
||||||
@@ -141,8 +132,8 @@ __all__ = [
|
|||||||
"login",
|
"login",
|
||||||
"remove_permission_from_org",
|
"remove_permission_from_org",
|
||||||
"remove_permission_from_role",
|
"remove_permission_from_role",
|
||||||
"set_config",
|
|
||||||
"set_session_host",
|
"set_session_host",
|
||||||
|
"update_config",
|
||||||
"update_credential_sign_count",
|
"update_credential_sign_count",
|
||||||
"update_org_name",
|
"update_org_name",
|
||||||
"update_permission",
|
"update_permission",
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from paskia.db.operations import _store, cleanup_expired
|
import paskia.db.operations as _ops
|
||||||
|
from paskia.db.lifecycle import cleanup_expired
|
||||||
|
|
||||||
FLUSH_INTERVAL = 0.1 # Flush to disk
|
FLUSH_INTERVAL = 0.1 # Flush to disk
|
||||||
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||||
@@ -20,11 +21,11 @@ _background_task: asyncio.Task | None = None
|
|||||||
|
|
||||||
async def flush() -> None:
|
async def flush() -> None:
|
||||||
"""Write all pending database changes to disk."""
|
"""Write all pending database changes to disk."""
|
||||||
|
store = _ops._store
|
||||||
if _store is None:
|
if store is None:
|
||||||
_logger.warning("flush() called but _store is None")
|
_logger.warning("flush() called but _store is None")
|
||||||
return
|
return
|
||||||
await _store.flush()
|
await store.flush()
|
||||||
|
|
||||||
|
|
||||||
async def _background_loop():
|
async def _background_loop():
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Bootstrap operations for initial system setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import uuid7
|
||||||
|
|
||||||
|
import paskia.db.operations as _ops
|
||||||
|
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap(
|
||||||
|
org_name: str = "Organization",
|
||||||
|
admin_name: str = "Admin",
|
||||||
|
reset_passphrase: str | None = None,
|
||||||
|
reset_expiry: datetime | None = None,
|
||||||
|
config: Config | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Bootstrap the entire system in a single transaction.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
- auth:admin permission (Master Admin)
|
||||||
|
- auth:org:admin permission (Org Admin)
|
||||||
|
- Organization with Administration role
|
||||||
|
- Admin user with Administration role
|
||||||
|
- Reset token for admin registration
|
||||||
|
- Config (if provided)
|
||||||
|
|
||||||
|
This is the only way to create a new database file.
|
||||||
|
All data is created atomically - if any step fails, nothing is written.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_name: Display name for the organization (default: "Organization")
|
||||||
|
admin_name: Display name for the admin user (default: "Admin")
|
||||||
|
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
||||||
|
reset_expiry: Expiry datetime for the reset token (default: 14 days)
|
||||||
|
config: Configuration to store (rp_id, rp_name, origins, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The reset passphrase for admin registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check if system is already bootstrapped
|
||||||
|
for p in _ops._db.permissions.values():
|
||||||
|
if p.scope == "auth:admin":
|
||||||
|
raise ValueError(
|
||||||
|
"System already bootstrapped (auth:admin permission exists)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate UUIDs upfront
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
perm_admin_uuid = uuid7.create(now)
|
||||||
|
perm_org_admin_uuid = uuid7.create(now)
|
||||||
|
org_uuid = uuid7.create(now)
|
||||||
|
role_uuid = uuid7.create(now)
|
||||||
|
user_uuid = uuid7.create(now)
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
with _ops._db.transaction("bootstrap"):
|
||||||
|
# Create auth:admin permission
|
||||||
|
perm_admin = Permission(
|
||||||
|
scope="auth:admin",
|
||||||
|
display_name="Master Admin",
|
||||||
|
orgs={org_uuid: True}, # Grant to org
|
||||||
|
)
|
||||||
|
perm_admin.uuid = perm_admin_uuid
|
||||||
|
perm_admin.store()
|
||||||
|
|
||||||
|
# Create auth:org:admin permission
|
||||||
|
perm_org_admin = Permission(
|
||||||
|
scope="auth:org:admin",
|
||||||
|
display_name="Org Admin",
|
||||||
|
orgs={org_uuid: True}, # Grant to org
|
||||||
|
)
|
||||||
|
perm_org_admin.uuid = perm_org_admin_uuid
|
||||||
|
perm_org_admin.store()
|
||||||
|
|
||||||
|
# Create organization
|
||||||
|
new_org = Org.create(display_name=org_name)
|
||||||
|
new_org.uuid = org_uuid
|
||||||
|
new_org.store()
|
||||||
|
|
||||||
|
# Create Administration role with both permissions
|
||||||
|
admin_role = Role(
|
||||||
|
org_uuid=org_uuid,
|
||||||
|
display_name="Administration",
|
||||||
|
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
||||||
|
)
|
||||||
|
admin_role.uuid = role_uuid
|
||||||
|
admin_role.store()
|
||||||
|
|
||||||
|
# Create admin user
|
||||||
|
admin_user = User(
|
||||||
|
display_name=admin_name,
|
||||||
|
role_uuid=role_uuid,
|
||||||
|
created_at=now,
|
||||||
|
last_seen=None,
|
||||||
|
visits=0,
|
||||||
|
)
|
||||||
|
admin_user.uuid = user_uuid
|
||||||
|
admin_user.store()
|
||||||
|
|
||||||
|
# Create reset token
|
||||||
|
reset_token, reset_passphrase = ResetToken.create(
|
||||||
|
user=user_uuid,
|
||||||
|
expiry=reset_expiry,
|
||||||
|
token_type="admin bootstrap",
|
||||||
|
passphrase=reset_passphrase,
|
||||||
|
)
|
||||||
|
reset_token.store()
|
||||||
|
|
||||||
|
# Set config if provided
|
||||||
|
if config is not None:
|
||||||
|
_ops._db.config = config
|
||||||
|
|
||||||
|
return reset_passphrase
|
||||||
+27
-21
@@ -4,6 +4,8 @@ JSONL persistence layer for the database.
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -69,22 +71,25 @@ def create_change_record(
|
|||||||
# Actions that are allowed to create a new database file
|
# Actions that are allowed to create a new database file
|
||||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
||||||
|
|
||||||
|
# Flag to prevent duplicate error messages on fatal flush failure
|
||||||
|
_flush_failed = False
|
||||||
|
|
||||||
|
|
||||||
async def flush_changes(
|
async def flush_changes(
|
||||||
db_path: Path,
|
db_path: Path,
|
||||||
pending_changes: deque[_ChangeRecord],
|
pending_changes: deque[_ChangeRecord],
|
||||||
) -> bool:
|
) -> None:
|
||||||
"""Write all pending changes to disk.
|
"""Write all pending changes to disk.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_path: Path to the JSONL database file
|
db_path: Path to the JSONL database file
|
||||||
pending_changes: Queue of pending change records (will be cleared on success)
|
pending_changes: Queue of pending change records (will be cleared on success)
|
||||||
|
|
||||||
Returns:
|
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
|
||||||
True if flush succeeded, False otherwise
|
|
||||||
"""
|
"""
|
||||||
if not pending_changes:
|
global _flush_failed
|
||||||
return True
|
if _flush_failed or not pending_changes:
|
||||||
|
return
|
||||||
|
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
first_action = pending_changes[0].a
|
first_action = pending_changes[0].a
|
||||||
@@ -94,26 +99,25 @@ async def flush_changes(
|
|||||||
"only bootstrap can create a new database",
|
"only bootstrap can create a new database",
|
||||||
first_action,
|
first_action,
|
||||||
)
|
)
|
||||||
pending_changes.clear()
|
_flush_failed = True
|
||||||
return False
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
return
|
||||||
|
|
||||||
changes_to_write = list(pending_changes)
|
changes_to_write = list(pending_changes)
|
||||||
pending_changes.clear()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lines = [_change_encoder.encode(change) for change in changes_to_write]
|
lines = [_change_encoder.encode(change) for change in changes_to_write]
|
||||||
if not lines:
|
if not lines:
|
||||||
return True
|
pending_changes.clear()
|
||||||
|
return
|
||||||
|
|
||||||
async with aiofiles.open(db_path, "ab") as f:
|
async with aiofiles.open(db_path, "ab") as f:
|
||||||
await f.write(b"\n".join(lines) + b"\n")
|
await f.write(b"\n".join(lines) + b"\n")
|
||||||
return True
|
pending_changes.clear()
|
||||||
except OSError:
|
except OSError as e:
|
||||||
_logger.exception("Failed to flush database changes")
|
_logger.error("Failed to flush database: %s", e)
|
||||||
# Re-queue the changes on failure
|
_flush_failed = True
|
||||||
for change in reversed(changes_to_write):
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
pending_changes.appendleft(change)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class JsonlStore:
|
class JsonlStore:
|
||||||
@@ -155,8 +159,10 @@ class JsonlStore:
|
|||||||
self._current_version = change.get("v", 0)
|
self._current_version = change.get("v", 0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Error parsing line {line_num}: {e}")
|
raise ValueError(f"Error parsing line {line_num}: {e}")
|
||||||
except (OSError, ValueError, msgspec.DecodeError) as e:
|
except OSError as e:
|
||||||
raise ValueError(f"Failed to load database: {e}")
|
raise SystemExit(f"Failed to load database: {e}")
|
||||||
|
except (ValueError, msgspec.DecodeError) as e:
|
||||||
|
raise SystemExit(f"Failed to load database: {e}")
|
||||||
|
|
||||||
if not data_dict:
|
if not data_dict:
|
||||||
return
|
return
|
||||||
@@ -214,7 +220,7 @@ class JsonlStore:
|
|||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
user_display = user
|
user_display = user
|
||||||
|
|
||||||
log_change(action, diff, user_display, self._previous_builtins)
|
log_change(action, diff, user_display, self._previous_builtins, self.db)
|
||||||
self._previous_builtins = copy.deepcopy(current)
|
self._previous_builtins = copy.deepcopy(current)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -282,6 +288,6 @@ class JsonlStore:
|
|||||||
self._in_transaction = False
|
self._in_transaction = False
|
||||||
self._transaction_snapshot = None
|
self._transaction_snapshot = None
|
||||||
|
|
||||||
async def flush(self) -> bool:
|
async def flush(self) -> None:
|
||||||
"""Write all pending changes to disk."""
|
"""Write all pending changes to disk."""
|
||||||
return await flush_changes(self.db_path, self._pending_changes)
|
await flush_changes(self.db_path, self._pending_changes)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Database lifecycle: initialization and maintenance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import paskia.db.operations as _ops
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def init(rp_id: str = "localhost", *args, **kwargs):
|
||||||
|
"""Load database from JSONL file."""
|
||||||
|
if _ops._initialized:
|
||||||
|
_logger.debug("Database already initialized, skipping reload")
|
||||||
|
return
|
||||||
|
default_path = f"{rp_id}.paskiadb"
|
||||||
|
db_path = os.environ.get("PASKIA_DB", default_path)
|
||||||
|
await _ops._store.load(db_path, rp_id=rp_id)
|
||||||
|
_ops._db = _ops._store.db
|
||||||
|
_ops._initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired() -> int:
|
||||||
|
"""Remove expired sessions and reset tokens. Returns count removed."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
count = 0
|
||||||
|
with _ops._db.transaction("expiry"):
|
||||||
|
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now]
|
||||||
|
for k in expired_sessions:
|
||||||
|
del _ops._db.sessions[k]
|
||||||
|
count += 1
|
||||||
|
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
|
||||||
|
for k in expired_tokens:
|
||||||
|
del _ops._db.reset_tokens[k]
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
+244
-43
@@ -3,15 +3,27 @@ Database change logging with pretty-printed diffs.
|
|||||||
|
|
||||||
Provides a logger for JSONL database changes that formats diffs
|
Provides a logger for JSONL database changes that formats diffs
|
||||||
in a human-readable path.notation style with color coding.
|
in a human-readable path.notation style with color coding.
|
||||||
|
|
||||||
|
UUIDs are replaced with display names where available, or the last
|
||||||
|
section of the UUID hex for types without display names.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from paskia.db.structs import DB
|
||||||
|
|
||||||
logger = logging.getLogger("paskia.db")
|
logger = logging.getLogger("paskia.db")
|
||||||
|
|
||||||
|
# UUID regex pattern (8-4-4-4-12 hex format)
|
||||||
|
_UUID_PATTERN = re.compile(
|
||||||
|
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||||
|
)
|
||||||
|
|
||||||
# Pattern to match control characters and bidirectional overrides
|
# Pattern to match control characters and bidirectional overrides
|
||||||
_UNSAFE_CHARS = re.compile(
|
_UNSAFE_CHARS = re.compile(
|
||||||
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
|
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
|
||||||
@@ -32,13 +44,145 @@ _ACTION = "\033[1;34m" # Bold blue for action name
|
|||||||
_USER = "\033[0;34m" # Blue for user display
|
_USER = "\033[0;34m" # Blue for user display
|
||||||
|
|
||||||
|
|
||||||
|
def _is_uuid(value: str) -> bool:
|
||||||
|
"""Check if a string is a UUID."""
|
||||||
|
return bool(_UUID_PATTERN.match(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_suffix(uuid_str: str) -> str:
|
||||||
|
"""Get the last section of a UUID (after the last hyphen)."""
|
||||||
|
return uuid_str.rsplit("-", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
class UuidResolver:
|
||||||
|
"""Resolve UUIDs to display names or short suffixes.
|
||||||
|
|
||||||
|
Uses the previous state for lookups to show the name before any changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
|
||||||
|
self._db = db
|
||||||
|
self._previous = previous
|
||||||
|
|
||||||
|
def resolve(self, uuid_str: str) -> str:
|
||||||
|
"""Resolve a UUID to its display name or short suffix."""
|
||||||
|
display = self._get_display_name(uuid_str)
|
||||||
|
if display:
|
||||||
|
return display
|
||||||
|
return _uuid_suffix(uuid_str)
|
||||||
|
|
||||||
|
def _get_display_name(self, uuid_str: str) -> str | None:
|
||||||
|
"""Look up display name for a UUID.
|
||||||
|
|
||||||
|
First checks the previous state (to show names before changes),
|
||||||
|
then falls back to the current database.
|
||||||
|
"""
|
||||||
|
# Try previous state first (for showing name before a change)
|
||||||
|
name = self._lookup_in_previous(uuid_str)
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Fall back to current database
|
||||||
|
return self._lookup_in_db(uuid_str)
|
||||||
|
|
||||||
|
def _lookup_in_previous(self, uuid_str: str) -> str | None:
|
||||||
|
"""Look up display name in the previous state dict."""
|
||||||
|
if not self._previous:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check users
|
||||||
|
if "users" in self._previous and uuid_str in self._previous["users"]:
|
||||||
|
user_data = self._previous["users"][uuid_str]
|
||||||
|
if isinstance(user_data, dict) and "display_name" in user_data:
|
||||||
|
return user_data["display_name"]
|
||||||
|
|
||||||
|
# Check orgs
|
||||||
|
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
|
||||||
|
org_data = self._previous["orgs"][uuid_str]
|
||||||
|
if isinstance(org_data, dict) and "display_name" in org_data:
|
||||||
|
return org_data["display_name"]
|
||||||
|
|
||||||
|
# Check roles
|
||||||
|
if "roles" in self._previous and uuid_str in self._previous["roles"]:
|
||||||
|
role_data = self._previous["roles"][uuid_str]
|
||||||
|
if isinstance(role_data, dict) and "display_name" in role_data:
|
||||||
|
return role_data["display_name"]
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if (
|
||||||
|
"permissions" in self._previous
|
||||||
|
and uuid_str in self._previous["permissions"]
|
||||||
|
):
|
||||||
|
perm_data = self._previous["permissions"][uuid_str]
|
||||||
|
if isinstance(perm_data, dict) and "display_name" in perm_data:
|
||||||
|
return perm_data["display_name"]
|
||||||
|
|
||||||
|
# Check credentials - look up user name
|
||||||
|
if (
|
||||||
|
"credentials" in self._previous
|
||||||
|
and uuid_str in self._previous["credentials"]
|
||||||
|
):
|
||||||
|
cred_data = self._previous["credentials"][uuid_str]
|
||||||
|
if isinstance(cred_data, dict) and "user" in cred_data:
|
||||||
|
user_uuid = cred_data["user"]
|
||||||
|
if "users" in self._previous and user_uuid in self._previous["users"]:
|
||||||
|
user_data = self._previous["users"][user_uuid]
|
||||||
|
if isinstance(user_data, dict) and "display_name" in user_data:
|
||||||
|
return f"credential of {user_data['display_name']}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _lookup_in_db(self, uuid_str: str) -> str | None:
|
||||||
|
"""Look up display name in the current database."""
|
||||||
|
if not self._db:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
uuid_obj = UUID(uuid_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check users
|
||||||
|
if uuid_obj in self._db.users:
|
||||||
|
return self._db.users[uuid_obj].display_name
|
||||||
|
|
||||||
|
# Check orgs
|
||||||
|
if uuid_obj in self._db.orgs:
|
||||||
|
return self._db.orgs[uuid_obj].display_name
|
||||||
|
|
||||||
|
# Check roles
|
||||||
|
if uuid_obj in self._db.roles:
|
||||||
|
return self._db.roles[uuid_obj].display_name
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if uuid_obj in self._db.permissions:
|
||||||
|
return self._db.permissions[uuid_obj].display_name
|
||||||
|
|
||||||
|
# Check credentials - identify by user name
|
||||||
|
if uuid_obj in self._db.credentials:
|
||||||
|
cred = self._db.credentials[uuid_obj]
|
||||||
|
if cred.user_uuid in self._db.users:
|
||||||
|
user_name = self._db.users[cred.user_uuid].display_name
|
||||||
|
return f"credential of {user_name}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _use_color() -> bool:
|
def _use_color() -> bool:
|
||||||
"""Check if we should use color output."""
|
"""Check if we should use color output."""
|
||||||
return sys.stderr.isatty()
|
return sys.stderr.isatty()
|
||||||
|
|
||||||
|
|
||||||
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
def _format_value(
|
||||||
"""Format a value for display, truncating if needed."""
|
value: Any,
|
||||||
|
use_color: bool,
|
||||||
|
max_len: int = 60,
|
||||||
|
resolver: UuidResolver | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Format a value for display, truncating if needed.
|
||||||
|
|
||||||
|
If resolver is provided, UUIDs are replaced with display names or short suffixes.
|
||||||
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return "null"
|
return "null"
|
||||||
|
|
||||||
@@ -49,6 +193,9 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
# Check if it's a UUID and resolve to display name
|
||||||
|
if resolver and _is_uuid(value):
|
||||||
|
return resolver.resolve(value)
|
||||||
# Filter out control characters and bidirectional overrides
|
# Filter out control characters and bidirectional overrides
|
||||||
value = _UNSAFE_CHARS.sub("", value)
|
value = _UNSAFE_CHARS.sub("", value)
|
||||||
# Truncate long strings
|
# Truncate long strings
|
||||||
@@ -59,18 +206,26 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
|||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
if not value:
|
if not value:
|
||||||
return "{}"
|
return "{}"
|
||||||
# For small dicts, show inline
|
# Check if all values are True - render as set-like {key1, key2}
|
||||||
if len(value) == 1:
|
all_true = all(v is True for v in value.values())
|
||||||
k, v = next(iter(value.items()))
|
parts = []
|
||||||
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}"
|
for k, v in value.items():
|
||||||
return f"{{...{len(value)} keys}}"
|
# Replace UUID keys with display names
|
||||||
|
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
|
||||||
|
if all_true:
|
||||||
|
parts.append(key_display)
|
||||||
|
else:
|
||||||
|
val_display = _format_value(v, use_color, max_len=30, resolver=resolver)
|
||||||
|
parts.append(f"{key_display}: {val_display}")
|
||||||
|
return "{" + ", ".join(parts) + "}"
|
||||||
|
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
if not value:
|
if not value:
|
||||||
return "[]"
|
return "[]"
|
||||||
if len(value) == 1:
|
parts = [
|
||||||
return "[" + _format_value(value[0], use_color, max_len=30) + "]"
|
_format_value(v, use_color, max_len=30, resolver=resolver) for v in value
|
||||||
return f"[...{len(value)} items]"
|
]
|
||||||
|
return "[" + ", ".join(parts) + "]"
|
||||||
|
|
||||||
# Fallback for other types
|
# Fallback for other types
|
||||||
text = str(value)
|
text = str(value)
|
||||||
@@ -79,10 +234,20 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _format_path(path: list[str], use_color: bool) -> str:
|
def _format_path(
|
||||||
"""Format a path as dot notation with prefix in dark grey, final in default."""
|
path: list[str], use_color: bool, resolver: UuidResolver | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Format a path as dot notation with prefix in dark grey, final in default.
|
||||||
|
|
||||||
|
If resolver is provided, UUIDs in the path are replaced with display names.
|
||||||
|
"""
|
||||||
if not path:
|
if not path:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
# Replace UUIDs in path with display names
|
||||||
|
if resolver:
|
||||||
|
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
|
||||||
|
|
||||||
if not use_color:
|
if not use_color:
|
||||||
return ".".join(path)
|
return ".".join(path)
|
||||||
if len(path) == 1:
|
if len(path) == 1:
|
||||||
@@ -176,16 +341,32 @@ def _collect_changes(
|
|||||||
|
|
||||||
|
|
||||||
def _format_change_lines(
|
def _format_change_lines(
|
||||||
change_type: str, path: list[str], value: Any, use_color: bool
|
change_type: str,
|
||||||
|
path: list[str],
|
||||||
|
value: Any,
|
||||||
|
use_color: bool,
|
||||||
|
resolver: UuidResolver | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Format a single change as one or more lines."""
|
"""Format a single change as one or more lines.
|
||||||
|
|
||||||
|
If resolver is provided, UUIDs are replaced with display names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Helper to format path with UUID replacement
|
||||||
|
def fmt_path(p: list[str]) -> list[str]:
|
||||||
|
if resolver:
|
||||||
|
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
|
||||||
|
return p
|
||||||
|
|
||||||
|
formatted_path = fmt_path(path)
|
||||||
|
|
||||||
if change_type == "delete":
|
if change_type == "delete":
|
||||||
if not use_color:
|
if not use_color:
|
||||||
return [f" {'.'.join(path)} ✗"]
|
return [f" {'.'.join(formatted_path)} ✗"]
|
||||||
if len(path) == 1:
|
if len(formatted_path) == 1:
|
||||||
return [f" {_DELETE}{path[0]} ✗{_RESET}"]
|
return [f" {_DELETE}{formatted_path[0]} ✗{_RESET}"]
|
||||||
prefix = ".".join(path[:-1])
|
prefix = ".".join(formatted_path[:-1])
|
||||||
final = path[-1]
|
final = formatted_path[-1]
|
||||||
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
||||||
|
|
||||||
if change_type == "add":
|
if change_type == "add":
|
||||||
@@ -195,56 +376,66 @@ def _format_change_lines(
|
|||||||
lines = []
|
lines = []
|
||||||
# First line: path with green final element and grey =
|
# First line: path with green final element and grey =
|
||||||
if not use_color:
|
if not use_color:
|
||||||
lines.append(f" {'.'.join(path)} =")
|
lines.append(f" {'.'.join(formatted_path)} =")
|
||||||
elif len(path) == 1:
|
elif len(formatted_path) == 1:
|
||||||
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
|
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET}")
|
||||||
else:
|
else:
|
||||||
prefix = ".".join(path[:-1])
|
prefix = ".".join(formatted_path[:-1])
|
||||||
final = path[-1]
|
final = formatted_path[-1]
|
||||||
lines.append(
|
lines.append(
|
||||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
||||||
)
|
)
|
||||||
# Child lines: indented key: value, with aligned values
|
# Child lines: indented key: value, with aligned values
|
||||||
max_key_len = max(len(k) for k in value.keys())
|
# Format keys (may contain UUIDs)
|
||||||
field_width = max(max_key_len, 12) # minimum 12 chars
|
formatted_items = []
|
||||||
for k, v in value.items():
|
for k, v in value.items():
|
||||||
v_str = _format_value(v, use_color)
|
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
|
||||||
padding = " " * (field_width - len(k))
|
v_str = _format_value(v, use_color, resolver=resolver)
|
||||||
|
formatted_items.append((k_display, v_str))
|
||||||
|
max_key_len = max(len(k) for k, _ in formatted_items)
|
||||||
|
field_width = max(max_key_len, 12) # minimum 12 chars
|
||||||
|
for k_display, v_str in formatted_items:
|
||||||
|
padding = " " * (field_width - len(k_display))
|
||||||
if use_color:
|
if use_color:
|
||||||
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
|
lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}")
|
||||||
else:
|
else:
|
||||||
lines.append(f" {k}:{padding} {v_str}")
|
lines.append(f" {k_display}:{padding} {v_str}")
|
||||||
return lines
|
return lines
|
||||||
else:
|
else:
|
||||||
value_str = _format_value(value, use_color)
|
value_str = _format_value(value, use_color, resolver=resolver)
|
||||||
if not use_color:
|
if not use_color:
|
||||||
return [f" {'.'.join(path)} = {value_str}"]
|
return [f" {'.'.join(formatted_path)} = {value_str}"]
|
||||||
if len(path) == 1:
|
if len(formatted_path) == 1:
|
||||||
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
|
return [
|
||||||
prefix = ".".join(path[:-1])
|
f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET} {value_str}"
|
||||||
final = path[-1]
|
]
|
||||||
|
prefix = ".".join(formatted_path[:-1])
|
||||||
|
final = formatted_path[-1]
|
||||||
return [
|
return [
|
||||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
||||||
]
|
]
|
||||||
|
|
||||||
# update: Existing item being updated - normal path colors
|
# update: Existing item being updated - normal path colors
|
||||||
value_str = _format_value(value, use_color)
|
value_str = _format_value(value, use_color, resolver=resolver)
|
||||||
path_str = _format_path(path, use_color)
|
path_str = _format_path(path, use_color, resolver=resolver)
|
||||||
if use_color:
|
if use_color:
|
||||||
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
||||||
return [f" {path_str} = {value_str}"]
|
return [f" {path_str} = {value_str}"]
|
||||||
|
|
||||||
|
|
||||||
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
|
def format_diff(
|
||||||
|
diff: dict, previous: dict | None = None, db: "DB | None" = None
|
||||||
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Format a JSON diff as human-readable lines.
|
Format a JSON diff as human-readable lines.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
diff: The JSON diff dict
|
diff: The JSON diff dict
|
||||||
previous: The previous state dict (for determining add vs update)
|
previous: The previous state dict (for determining add vs update)
|
||||||
|
db: Optional database for looking up display names
|
||||||
|
|
||||||
Returns a list of formatted lines (without newlines).
|
Returns a list of formatted lines (without newlines).
|
||||||
Single changes return one line, multiple changes return multiple lines.
|
UUIDs are replaced with display names (using previous state for lookups).
|
||||||
"""
|
"""
|
||||||
use_color = _use_color()
|
use_color = _use_color()
|
||||||
changes: list[tuple[str, list[str], Any]] = []
|
changes: list[tuple[str, list[str], Any]] = []
|
||||||
@@ -253,10 +444,15 @@ def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
|
|||||||
if not changes:
|
if not changes:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Create resolver for UUID replacement (uses previous state for lookups)
|
||||||
|
resolver = UuidResolver(db, previous)
|
||||||
|
|
||||||
# Format each change
|
# Format each change
|
||||||
lines = []
|
lines = []
|
||||||
for change_type, path, value in changes:
|
for change_type, path, value in changes:
|
||||||
lines.extend(_format_change_lines(change_type, path, value, use_color))
|
lines.extend(
|
||||||
|
_format_change_lines(change_type, path, value, use_color, resolver)
|
||||||
|
)
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
@@ -282,18 +478,23 @@ def log_change(
|
|||||||
diff: dict,
|
diff: dict,
|
||||||
user_display: str | None = None,
|
user_display: str | None = None,
|
||||||
previous: dict | None = None,
|
previous: dict | None = None,
|
||||||
|
db: "DB | None" = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Log a database change with pretty-printed diff.
|
Log a database change with pretty-printed diff.
|
||||||
|
|
||||||
|
UUIDs are replaced with display names for readability. For types without
|
||||||
|
display names (e.g., credentials), the last section of the UUID is used.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action: The action name (e.g., "login", "admin:delete_user")
|
action: The action name (e.g., "login", "admin:delete_user")
|
||||||
diff: The JSON diff dict
|
diff: The JSON diff dict
|
||||||
user_display: Optional display name of the user who performed the action
|
user_display: Optional display name of the user who performed the action
|
||||||
previous: The previous state dict (for determining add vs update)
|
previous: The previous state dict (for determining add vs update)
|
||||||
|
db: Optional database for looking up display names
|
||||||
"""
|
"""
|
||||||
header = format_action_header(action, user_display)
|
header = format_action_header(action, user_display)
|
||||||
diff_lines = format_diff(diff, previous)
|
diff_lines = format_diff(diff, previous, db)
|
||||||
|
|
||||||
if not diff_lines:
|
if not diff_lines:
|
||||||
logger.info(header)
|
logger.info(header)
|
||||||
|
|||||||
+46
-298
@@ -6,11 +6,8 @@ Context lookup: _db.session_ctx() returns full SessionContext with effective per
|
|||||||
Write operations: Functions that validate and commit, or raise ValueError.
|
Write operations: Functions that validate and commit, or raise ValueError.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
from datetime import UTC, datetime, timedelta
|
||||||
import secrets
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import uuid7
|
import uuid7
|
||||||
@@ -31,105 +28,33 @@ from paskia.db.structs import (
|
|||||||
SessionContext,
|
SessionContext,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from paskia.util.passphrase import is_well_formed as _is_passphrase
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Global database instance (empty until init() loads data)
|
# Global database instance (empty until init() loads data)
|
||||||
_db = DB()
|
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||||
_store = JsonlStore(_db)
|
_store = JsonlStore(_db)
|
||||||
_db._store = _store
|
_db._store = _store
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
|
|
||||||
async def init(rp_id: str = "localhost", *args, **kwargs):
|
|
||||||
"""Load database from JSONL file."""
|
|
||||||
global _db, _initialized
|
|
||||||
if _initialized:
|
|
||||||
_logger.debug("Database already initialized, skipping reload")
|
|
||||||
return
|
|
||||||
default_path = f"{rp_id}.paskiadb"
|
|
||||||
db_path = os.environ.get("PASKIA_DB", default_path)
|
|
||||||
await _store.load(db_path, rp_id=rp_id)
|
|
||||||
_db = _store.db
|
|
||||||
_initialized = True
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Read/lookup functions
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
|
|
||||||
"""Get the organization a user belongs to and their role name.
|
|
||||||
|
|
||||||
Raises ValueError if user not found.
|
|
||||||
|
|
||||||
Call sites:
|
|
||||||
- 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")
|
|
||||||
user = _db.users[user_uuid]
|
|
||||||
role = user.role
|
|
||||||
return role.org, role.display_name
|
|
||||||
|
|
||||||
|
|
||||||
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
|
|
||||||
"""Get all users in an organization with their role names.
|
|
||||||
|
|
||||||
Returns list of (User, role_display_name) tuples.
|
|
||||||
"""
|
|
||||||
org = _db.orgs[org_uuid]
|
|
||||||
return [(u, u.role.display_name) for role in org.roles for u in role.users]
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
|
|
||||||
"""Get credential IDs for a user (for WebAuthn exclude lists).
|
|
||||||
|
|
||||||
Returns empty list if user has no credentials.
|
|
||||||
"""
|
|
||||||
assert user_uuid
|
|
||||||
return [c.credential_id for c in _db.users[user_uuid].credentials]
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_key(passphrase: str) -> bytes:
|
|
||||||
"""Hash a passphrase to bytes for reset token storage."""
|
|
||||||
if not _is_passphrase(passphrase):
|
|
||||||
raise ValueError(
|
|
||||||
"Trying to reset with a session token in place of a passphrase"
|
|
||||||
if len(passphrase) == 16
|
|
||||||
else "Invalid passphrase format"
|
|
||||||
)
|
|
||||||
return hashlib.sha512(passphrase.encode()).digest()[:9]
|
|
||||||
|
|
||||||
|
|
||||||
def get_reset_token(passphrase: str) -> ResetToken | None:
|
|
||||||
"""Get reset token by passphrase.
|
|
||||||
|
|
||||||
Call sites:
|
|
||||||
- Get reset token to validate it (authsession.py:34)
|
|
||||||
"""
|
|
||||||
key = _reset_key(passphrase)
|
|
||||||
return _db.reset_tokens.get(key)
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Write operations (validate, modify, commit or raise ValueError)
|
# Write operations (validate, modify, commit or raise ValueError)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def update_config(config: Config) -> None:
|
||||||
|
"""Update the stored configuration."""
|
||||||
|
with _db.transaction("update_config"):
|
||||||
|
_db.config = config
|
||||||
|
|
||||||
|
|
||||||
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
|
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
|
||||||
"""Create a new permission."""
|
"""Create a new permission."""
|
||||||
if perm.uuid in _db.permissions:
|
if perm.uuid in _db.permissions:
|
||||||
raise ValueError(f"Permission {perm.uuid} already exists")
|
raise ValueError(f"Permission {perm.uuid} already exists")
|
||||||
with _db.transaction("admin:create_permission", ctx):
|
with _db.transaction("admin:create_permission", ctx):
|
||||||
_db.permissions[perm.uuid] = perm
|
perm.store()
|
||||||
|
|
||||||
|
|
||||||
def update_permission(
|
def update_permission(
|
||||||
@@ -157,10 +82,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if uuid not in _db.permissions:
|
if uuid not in _db.permissions:
|
||||||
raise ValueError(f"Permission {uuid} not found")
|
raise ValueError(f"Permission {uuid} not found")
|
||||||
with _db.transaction("admin:delete_permission", ctx):
|
with _db.transaction("admin:delete_permission", ctx):
|
||||||
# Remove this permission from all roles
|
_db.permissions[uuid].delete()
|
||||||
for role in _db.roles.values():
|
|
||||||
role.permissions.pop(uuid, None)
|
|
||||||
del _db.permissions[uuid]
|
|
||||||
|
|
||||||
|
|
||||||
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
||||||
@@ -170,13 +92,14 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
|||||||
"""
|
"""
|
||||||
if org.uuid in _db.orgs:
|
if org.uuid in _db.orgs:
|
||||||
raise ValueError(f"Organization {org.uuid} already exists")
|
raise ValueError(f"Organization {org.uuid} already exists")
|
||||||
|
now = datetime.now(UTC)
|
||||||
with _db.transaction("admin:create_org", ctx):
|
with _db.transaction("admin:create_org", ctx):
|
||||||
new_org = Org.create(display_name=org.display_name)
|
new_org = Org.create(display_name=org.display_name, created_at=now)
|
||||||
new_org.uuid = org.uuid
|
new_org.uuid = org.uuid
|
||||||
_db.orgs[org.uuid] = new_org
|
new_org.store()
|
||||||
# Create Administration role with org admin permission
|
# Create Administration role with org admin permission
|
||||||
|
|
||||||
admin_role_uuid = uuid7.create()
|
admin_role_uuid = uuid7.create(now)
|
||||||
# Find the auth:org:admin permission UUID
|
# Find the auth:org:admin permission UUID
|
||||||
org_admin_perm_uuid = None
|
org_admin_perm_uuid = None
|
||||||
for pid, p in _db.permissions.items():
|
for pid, p in _db.permissions.items():
|
||||||
@@ -190,7 +113,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
|
|||||||
permissions=role_permissions,
|
permissions=role_permissions,
|
||||||
)
|
)
|
||||||
admin_role.uuid = admin_role_uuid
|
admin_role.uuid = admin_role_uuid
|
||||||
_db.roles[admin_role_uuid] = admin_role
|
admin_role.store()
|
||||||
|
|
||||||
|
|
||||||
def update_org_name(
|
def update_org_name(
|
||||||
@@ -211,16 +134,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if uuid not in _db.orgs:
|
if uuid not in _db.orgs:
|
||||||
raise ValueError(f"Organization {uuid} not found")
|
raise ValueError(f"Organization {uuid} not found")
|
||||||
with _db.transaction("admin:delete_org", ctx):
|
with _db.transaction("admin:delete_org", ctx):
|
||||||
org = _db.orgs[uuid]
|
_db.orgs[uuid].delete()
|
||||||
# Remove org from all permissions
|
|
||||||
for p in _db.permissions.values():
|
|
||||||
p.orgs.pop(uuid, None)
|
|
||||||
# Delete roles in this org and their users
|
|
||||||
for role in org.roles:
|
|
||||||
for user in role.users:
|
|
||||||
del _db.users[user.uuid]
|
|
||||||
del _db.roles[role.uuid]
|
|
||||||
del _db.orgs[uuid]
|
|
||||||
|
|
||||||
|
|
||||||
def add_permission_to_org(
|
def add_permission_to_org(
|
||||||
@@ -264,7 +178,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if role.org_uuid not in _db.orgs:
|
if role.org_uuid not in _db.orgs:
|
||||||
raise ValueError(f"Organization {role.org_uuid} not found")
|
raise ValueError(f"Organization {role.org_uuid} not found")
|
||||||
with _db.transaction("admin:create_role", ctx):
|
with _db.transaction("admin:create_role", ctx):
|
||||||
_db.roles[role.uuid] = role
|
role.store()
|
||||||
|
|
||||||
|
|
||||||
def update_role_name(
|
def update_role_name(
|
||||||
@@ -317,7 +231,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if role.users:
|
if role.users:
|
||||||
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
|
||||||
with _db.transaction("admin:delete_role", ctx):
|
with _db.transaction("admin:delete_role", ctx):
|
||||||
del _db.roles[uuid]
|
_db.roles[uuid].delete()
|
||||||
|
|
||||||
|
|
||||||
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
||||||
@@ -327,7 +241,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
|
|||||||
if new_user.role_uuid not in _db.roles:
|
if new_user.role_uuid not in _db.roles:
|
||||||
raise ValueError(f"Role {new_user.role_uuid} not found")
|
raise ValueError(f"Role {new_user.role_uuid} not found")
|
||||||
with _db.transaction("admin:create_user", ctx):
|
with _db.transaction("admin:create_user", ctx):
|
||||||
_db.users[new_user.uuid] = new_user
|
new_user.store()
|
||||||
|
|
||||||
|
|
||||||
def update_user_display_name(
|
def update_user_display_name(
|
||||||
@@ -386,19 +300,8 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
|||||||
"""Delete user and their credentials/sessions."""
|
"""Delete user and their credentials/sessions."""
|
||||||
if uuid not in _db.users:
|
if uuid not in _db.users:
|
||||||
raise ValueError(f"User {uuid} not found")
|
raise ValueError(f"User {uuid} not found")
|
||||||
user = _db.users[uuid]
|
|
||||||
with _db.transaction("admin:delete_user", ctx):
|
with _db.transaction("admin:delete_user", ctx):
|
||||||
# Delete credentials
|
_db.users[uuid].delete()
|
||||||
for cred in user.credentials:
|
|
||||||
del _db.credentials[cred.uuid]
|
|
||||||
# Delete sessions
|
|
||||||
for sess in user.sessions:
|
|
||||||
del _db.sessions[sess.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]
|
|
||||||
|
|
||||||
|
|
||||||
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
|
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
|
||||||
@@ -408,7 +311,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
|
|||||||
if cred.user_uuid not in _db.users:
|
if cred.user_uuid not in _db.users:
|
||||||
raise ValueError(f"User {cred.user_uuid} not found")
|
raise ValueError(f"User {cred.user_uuid} not found")
|
||||||
with _db.transaction("create_credential", ctx):
|
with _db.transaction("create_credential", ctx):
|
||||||
_db.credentials[cred.uuid] = cred
|
cred.store()
|
||||||
|
|
||||||
|
|
||||||
def update_credential_sign_count(
|
def update_credential_sign_count(
|
||||||
@@ -444,11 +347,7 @@ def delete_credential(
|
|||||||
if cred.user_uuid != user_uuid:
|
if cred.user_uuid != user_uuid:
|
||||||
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
|
||||||
with _db.transaction("delete_credential", ctx):
|
with _db.transaction("delete_credential", ctx):
|
||||||
# Delete all sessions using this credential
|
cred.delete()
|
||||||
for sess in cred.sessions:
|
|
||||||
print(sess, repr(sess.key))
|
|
||||||
del _db.sessions[sess.key]
|
|
||||||
del _db.credentials[uuid]
|
|
||||||
|
|
||||||
|
|
||||||
def create_session(
|
def create_session(
|
||||||
@@ -457,7 +356,7 @@ def create_session(
|
|||||||
host: str,
|
host: str,
|
||||||
ip: str,
|
ip: str,
|
||||||
user_agent: str,
|
user_agent: str,
|
||||||
expiry: datetime,
|
duration: timedelta = SESSION_LIFETIME,
|
||||||
*,
|
*,
|
||||||
ctx: SessionContext | None = None,
|
ctx: SessionContext | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -466,18 +365,19 @@ def create_session(
|
|||||||
raise ValueError(f"User {user_uuid} not found")
|
raise ValueError(f"User {user_uuid} not found")
|
||||||
if credential_uuid not in _db.credentials:
|
if credential_uuid not in _db.credentials:
|
||||||
raise ValueError(f"Credential {credential_uuid} not found")
|
raise ValueError(f"Credential {credential_uuid} not found")
|
||||||
|
now = datetime.now(UTC)
|
||||||
session = Session.create(
|
session = Session.create(
|
||||||
user=user_uuid,
|
user=user_uuid,
|
||||||
credential=credential_uuid,
|
credential=credential_uuid,
|
||||||
host=host,
|
host=host,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
expiry=expiry,
|
expiry=now + duration,
|
||||||
)
|
)
|
||||||
if session.key in _db.sessions:
|
if session.key in _db.sessions:
|
||||||
raise ValueError("Session already exists")
|
raise ValueError("Session already exists")
|
||||||
with _db.transaction("create_session", ctx):
|
with _db.transaction("create_session", ctx):
|
||||||
_db.sessions[session.key] = session
|
session.store(now)
|
||||||
return session.key
|
return session.key
|
||||||
|
|
||||||
|
|
||||||
@@ -522,7 +422,7 @@ def delete_session(
|
|||||||
if key not in _db.sessions:
|
if key not in _db.sessions:
|
||||||
raise ValueError("Session not found")
|
raise ValueError("Session not found")
|
||||||
with _db.transaction(action, ctx):
|
with _db.transaction(action, ctx):
|
||||||
del _db.sessions[key]
|
_db.sessions[key].delete()
|
||||||
|
|
||||||
|
|
||||||
def delete_sessions_for_user(
|
def delete_sessions_for_user(
|
||||||
@@ -539,7 +439,7 @@ def delete_sessions_for_user(
|
|||||||
return
|
return
|
||||||
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
with _db.transaction("admin:delete_sessions_for_user", ctx):
|
||||||
for sess in user.sessions:
|
for sess in user.sessions:
|
||||||
del _db.sessions[sess.key]
|
sess.delete()
|
||||||
|
|
||||||
|
|
||||||
def create_reset_token(
|
def create_reset_token(
|
||||||
@@ -569,7 +469,7 @@ def create_reset_token(
|
|||||||
if token.key in _db.reset_tokens:
|
if token.key in _db.reset_tokens:
|
||||||
raise ValueError("Reset token already exists")
|
raise ValueError("Reset token already exists")
|
||||||
with _db.transaction("create_reset_token", ctx, user=user):
|
with _db.transaction("create_reset_token", ctx, user=user):
|
||||||
_db.reset_tokens[token.key] = token
|
token.store()
|
||||||
return passphrase
|
return passphrase
|
||||||
|
|
||||||
|
|
||||||
@@ -578,28 +478,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
|
|||||||
if key not in _db.reset_tokens:
|
if key not in _db.reset_tokens:
|
||||||
raise ValueError("Reset token not found")
|
raise ValueError("Reset token not found")
|
||||||
with _db.transaction("delete_reset_token", ctx):
|
with _db.transaction("delete_reset_token", ctx):
|
||||||
del _db.reset_tokens[key]
|
_db.reset_tokens[key].delete()
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Cleanup (called by background task)
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_expired() -> int:
|
|
||||||
"""Remove expired sessions and reset tokens. Returns count removed."""
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
count = 0
|
|
||||||
with _db.transaction("expiry"):
|
|
||||||
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
|
|
||||||
for k in expired_sessions:
|
|
||||||
del _db.sessions[k]
|
|
||||||
count += 1
|
|
||||||
expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
|
|
||||||
for k in expired_tokens:
|
|
||||||
del _db.reset_tokens[k]
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -607,11 +486,6 @@ def cleanup_expired() -> int:
|
|||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _create_token() -> str:
|
|
||||||
"""Generate a 16-character URL-safe session token."""
|
|
||||||
return secrets.token_urlsafe(12)
|
|
||||||
|
|
||||||
|
|
||||||
def login(
|
def login(
|
||||||
user_uuid: UUID,
|
user_uuid: UUID,
|
||||||
credential_uuid: UUID,
|
credential_uuid: UUID,
|
||||||
@@ -619,7 +493,7 @@ def login(
|
|||||||
host: str,
|
host: str,
|
||||||
ip: str,
|
ip: str,
|
||||||
user_agent: str,
|
user_agent: str,
|
||||||
expiry: datetime,
|
duration: timedelta = SESSION_LIFETIME,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Update user/credential on login and create session in a single transaction.
|
"""Update user/credential on login and create session in a single transaction.
|
||||||
|
|
||||||
@@ -645,18 +519,14 @@ def login(
|
|||||||
host=host,
|
host=host,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
expiry=expiry,
|
expiry=now + duration,
|
||||||
)
|
)
|
||||||
user_str = str(user_uuid)
|
user_str = str(user_uuid)
|
||||||
with _db.transaction("login", user=user_str):
|
with _db.transaction("login", user=user_str):
|
||||||
# Update user
|
session.store(now)
|
||||||
_db.users[user_uuid].last_seen = now
|
|
||||||
_db.users[user_uuid].visits += 1
|
|
||||||
# Update credential
|
# Update credential
|
||||||
_db.credentials[credential_uuid].sign_count = sign_count
|
_db.credentials[credential_uuid].sign_count = sign_count
|
||||||
_db.credentials[credential_uuid].last_used = now
|
_db.credentials[credential_uuid].last_used = now
|
||||||
# Create session
|
|
||||||
_db.sessions[session.key] = session
|
|
||||||
return session.key
|
return session.key
|
||||||
|
|
||||||
|
|
||||||
@@ -681,7 +551,6 @@ def create_credential_session(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
expiry = now + SESSION_LIFETIME
|
|
||||||
|
|
||||||
if user_uuid not in _db.users:
|
if user_uuid not in _db.users:
|
||||||
raise ValueError(f"User {user_uuid} not found")
|
raise ValueError(f"User {user_uuid} not found")
|
||||||
@@ -692,7 +561,7 @@ def create_credential_session(
|
|||||||
host=host,
|
host=host,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
expiry=expiry,
|
expiry=now + SESSION_LIFETIME,
|
||||||
)
|
)
|
||||||
user_str = str(user_uuid)
|
user_str = str(user_uuid)
|
||||||
with _db.transaction("create_credential_session", user=user_str):
|
with _db.transaction("create_credential_session", user=user_str):
|
||||||
@@ -700,141 +569,20 @@ def create_credential_session(
|
|||||||
if display_name:
|
if display_name:
|
||||||
_db.users[user_uuid].display_name = display_name
|
_db.users[user_uuid].display_name = display_name
|
||||||
|
|
||||||
# Create credential
|
# Align credential timestamps with transaction time
|
||||||
_db.credentials[credential.uuid] = credential
|
credential.created_at = now
|
||||||
|
credential.last_used = now
|
||||||
|
credential.last_verified = now
|
||||||
|
|
||||||
# Create session
|
# Create credential
|
||||||
_db.sessions[session.key] = session
|
credential.store()
|
||||||
|
|
||||||
|
# Store session and record visit
|
||||||
|
session.store(now)
|
||||||
|
|
||||||
# Delete reset token if provided
|
# Delete reset token if provided
|
||||||
if reset_key:
|
if reset_key:
|
||||||
if reset_key in _db.reset_tokens:
|
token = _db.reset_tokens.get(reset_key)
|
||||||
del _db.reset_tokens[reset_key]
|
if token:
|
||||||
|
token.delete()
|
||||||
return session.key
|
return session.key
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Bootstrap (single transaction for initial system setup)
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap(
|
|
||||||
org_name: str = "Organization",
|
|
||||||
admin_name: str = "Admin",
|
|
||||||
reset_passphrase: str | None = None,
|
|
||||||
reset_expiry: datetime | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Bootstrap the entire system in a single transaction.
|
|
||||||
|
|
||||||
Creates:
|
|
||||||
- auth:admin permission (Master Admin)
|
|
||||||
- auth:org:admin permission (Org Admin)
|
|
||||||
- Organization with Administration role
|
|
||||||
- Admin user with Administration role
|
|
||||||
- Reset token for admin registration
|
|
||||||
|
|
||||||
This is the only way to create a new database file.
|
|
||||||
All data is created atomically - if any step fails, nothing is written.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
org_name: Display name for the organization (default: "Organization")
|
|
||||||
admin_name: Display name for the admin user (default: "Admin")
|
|
||||||
reset_passphrase: Passphrase for the reset token (generated if not provided)
|
|
||||||
reset_expiry: Expiry datetime for the reset token (default: 14 days)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The reset passphrase for admin registration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Check if system is already bootstrapped
|
|
||||||
for p in _db.permissions.values():
|
|
||||||
if p.scope == "auth:admin":
|
|
||||||
raise ValueError(
|
|
||||||
"System already bootstrapped (auth:admin permission exists)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate UUIDs upfront
|
|
||||||
perm_admin_uuid = uuid7.create()
|
|
||||||
perm_org_admin_uuid = uuid7.create()
|
|
||||||
org_uuid = uuid7.create()
|
|
||||||
role_uuid = uuid7.create()
|
|
||||||
user_uuid = uuid7.create()
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
|
|
||||||
with _db.transaction("bootstrap"):
|
|
||||||
# Create auth:admin permission
|
|
||||||
perm_admin = Permission(
|
|
||||||
scope="auth:admin",
|
|
||||||
display_name="Master Admin",
|
|
||||||
orgs={org_uuid: True}, # Grant to org
|
|
||||||
)
|
|
||||||
perm_admin.uuid = perm_admin_uuid
|
|
||||||
_db.permissions[perm_admin_uuid] = perm_admin
|
|
||||||
|
|
||||||
# Create auth:org:admin permission
|
|
||||||
perm_org_admin = Permission(
|
|
||||||
scope="auth:org:admin",
|
|
||||||
display_name="Org Admin",
|
|
||||||
orgs={org_uuid: True}, # Grant to org
|
|
||||||
)
|
|
||||||
perm_org_admin.uuid = perm_org_admin_uuid
|
|
||||||
_db.permissions[perm_org_admin_uuid] = perm_org_admin
|
|
||||||
|
|
||||||
# Create organization
|
|
||||||
new_org = Org.create(display_name=org_name)
|
|
||||||
new_org.uuid = org_uuid
|
|
||||||
_db.orgs[org_uuid] = new_org
|
|
||||||
|
|
||||||
# Create Administration role with both permissions
|
|
||||||
admin_role = Role(
|
|
||||||
org_uuid=org_uuid,
|
|
||||||
display_name="Administration",
|
|
||||||
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
|
|
||||||
)
|
|
||||||
admin_role.uuid = role_uuid
|
|
||||||
_db.roles[role_uuid] = admin_role
|
|
||||||
|
|
||||||
# Create admin user
|
|
||||||
admin_user = User(
|
|
||||||
display_name=admin_name,
|
|
||||||
role_uuid=role_uuid,
|
|
||||||
created_at=now,
|
|
||||||
last_seen=None,
|
|
||||||
visits=0,
|
|
||||||
)
|
|
||||||
admin_user.uuid = user_uuid
|
|
||||||
_db.users[user_uuid] = admin_user
|
|
||||||
|
|
||||||
# Create reset token
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
# Config operations
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> Config:
|
|
||||||
"""Get the stored configuration."""
|
|
||||||
return _db.config
|
|
||||||
|
|
||||||
|
|
||||||
async def set_config(config: Config) -> None:
|
|
||||||
"""Update the stored configuration."""
|
|
||||||
async with _db.transaction("update_config"):
|
|
||||||
_db.config = config
|
|
||||||
|
|||||||
+128
-11
@@ -7,11 +7,10 @@ from uuid import UUID
|
|||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
import uuid7
|
import uuid7
|
||||||
from msgspec import field
|
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.util.hostutil import normalize_host
|
from paskia.util import hostutil
|
||||||
from paskia.util.passphrase import generate as generate_passphrase
|
from paskia.util import passphrase as passphrase_util
|
||||||
|
|
||||||
# Sentinel for uuid fields before they are set by create() or DB post init
|
# Sentinel for uuid fields before they are set by create() or DB post init
|
||||||
_UUID_UNSET = UUID(int=0)
|
_UUID_UNSET = UUID(int=0)
|
||||||
@@ -48,20 +47,36 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
if org_uuid in db.data().orgs
|
if org_uuid in db.data().orgs
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this permission in the database. Must be called inside a transaction."""
|
||||||
|
db.data().permissions[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this permission and remove it from all roles.
|
||||||
|
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
for role in _data.roles.values():
|
||||||
|
role.permissions.pop(self.uuid, None)
|
||||||
|
del _data.permissions[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
scope: str,
|
scope: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
domain: str | None = None,
|
domain: str | None = None,
|
||||||
|
created_at: datetime | None = None,
|
||||||
) -> Permission:
|
) -> Permission:
|
||||||
"""Create a new Permission with auto-generated uuid7."""
|
"""Create a new Permission with auto-generated uuid7."""
|
||||||
|
now = created_at or datetime.now(UTC)
|
||||||
perm = cls(
|
perm = cls(
|
||||||
scope=scope,
|
scope=scope,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
)
|
)
|
||||||
perm.uuid = uuid7.create()
|
perm.uuid = uuid7.create(now)
|
||||||
return perm
|
return perm
|
||||||
|
|
||||||
|
|
||||||
@@ -84,11 +99,30 @@ class Org(msgspec.Struct, dict=True):
|
|||||||
"""Get all permissions that this organization can grant."""
|
"""Get all permissions that this organization can grant."""
|
||||||
return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
|
return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this organization in the database. Must be called inside a transaction."""
|
||||||
|
db.data().orgs[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this org and cascade to roles, users. Remove from permissions.
|
||||||
|
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
for p in _data.permissions.values():
|
||||||
|
p.orgs.pop(self.uuid, None)
|
||||||
|
for role in self.roles:
|
||||||
|
for user in role.users:
|
||||||
|
del _data.users[user.uuid]
|
||||||
|
del _data.roles[role.uuid]
|
||||||
|
del _data.orgs[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, display_name: str) -> Org:
|
def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
|
||||||
"""Create a new Org with auto-generated uuid7."""
|
"""Create a new Org with auto-generated uuid7."""
|
||||||
|
now = created_at or datetime.now(UTC)
|
||||||
org = cls(display_name=display_name)
|
org = cls(display_name=display_name)
|
||||||
org.uuid = uuid7.create()
|
org.uuid = uuid7.create(now)
|
||||||
return org
|
return org
|
||||||
|
|
||||||
|
|
||||||
@@ -132,21 +166,31 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
"""Get all users that have this role."""
|
"""Get all users that have this role."""
|
||||||
return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
|
return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this role in the database. Must be called inside a transaction."""
|
||||||
|
db.data().roles[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this role from the database. Must be called inside a transaction."""
|
||||||
|
del db.data().roles[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
org: UUID | Org,
|
org: UUID | Org,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
permissions: set[UUID] | None = None,
|
permissions: set[UUID] | None = None,
|
||||||
|
created_at: datetime | None = None,
|
||||||
) -> Role:
|
) -> Role:
|
||||||
"""Create a new Role with auto-generated uuid7."""
|
"""Create a new Role with auto-generated uuid7."""
|
||||||
|
now = created_at or datetime.now(UTC)
|
||||||
org_uuid = org if isinstance(org, UUID) else org.uuid
|
org_uuid = org if isinstance(org, UUID) else org.uuid
|
||||||
role = cls(
|
role = cls(
|
||||||
org_uuid=org_uuid,
|
org_uuid=org_uuid,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
permissions={p: True for p in (permissions or set())},
|
permissions={p: True for p in (permissions or set())},
|
||||||
)
|
)
|
||||||
role.uuid = uuid7.create()
|
role.uuid = uuid7.create(now)
|
||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
@@ -184,6 +228,11 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
"""Get all credentials for this user."""
|
"""Get all credentials for this user."""
|
||||||
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
|
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credential_ids(self) -> list[bytes]:
|
||||||
|
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
|
||||||
|
return [c.credential_id for c in self.credentials]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sessions(self) -> list[Session]:
|
def sessions(self) -> list[Session]:
|
||||||
"""Get all sessions for this user."""
|
"""Get all sessions for this user."""
|
||||||
@@ -194,6 +243,24 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
|
|||||||
"""Get all reset tokens for this user."""
|
"""Get all reset tokens for this user."""
|
||||||
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
|
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this user in the database. Must be called inside a transaction."""
|
||||||
|
db.data().users[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this user and cascade to credentials, sessions, reset tokens.
|
||||||
|
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
for cred in self.credentials:
|
||||||
|
del _data.credentials[cred.uuid]
|
||||||
|
for sess in self.sessions:
|
||||||
|
del _data.sessions[sess.key]
|
||||||
|
for token in self.reset_tokens:
|
||||||
|
del _data.reset_tokens[token.key]
|
||||||
|
del _data.users[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
@@ -245,6 +312,20 @@ class Credential(msgspec.Struct, dict=True):
|
|||||||
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
|
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this credential in the database. Must be called inside a transaction."""
|
||||||
|
db.data().credentials[self.uuid] = self
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this credential and all its sessions.
|
||||||
|
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
for sess in self.sessions:
|
||||||
|
del _data.sessions[sess.key]
|
||||||
|
del _data.credentials[self.uuid]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
@@ -309,6 +390,21 @@ class Session(msgspec.Struct, dict=True):
|
|||||||
"expiry": self.expiry.isoformat(),
|
"expiry": self.expiry.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def store(self, last_seen: datetime) -> None:
|
||||||
|
"""Store this session in the database and record a visit.
|
||||||
|
|
||||||
|
Updates user.last_seen and user.visits. Must be called inside
|
||||||
|
a database transaction.
|
||||||
|
"""
|
||||||
|
_data = db.data()
|
||||||
|
_data.sessions[self.key] = self
|
||||||
|
_data.users[self.user_uuid].last_seen = last_seen
|
||||||
|
_data.users[self.user_uuid].visits += 1
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Delete this session from the database. Must be called inside a transaction."""
|
||||||
|
del db.data().sessions[self.key]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
@@ -356,6 +452,27 @@ class ResetToken(msgspec.Struct, dict=True):
|
|||||||
"""Get the User object for this reset token."""
|
"""Get the User object for this reset token."""
|
||||||
return db.data().users[self.user_uuid]
|
return db.data().users[self.user_uuid]
|
||||||
|
|
||||||
|
def store(self) -> None:
|
||||||
|
"""Store this reset token in the database. Must be called inside a transaction."""
|
||||||
|
db.data().reset_tokens[self.key] = self
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash(passphrase: str) -> bytes:
|
||||||
|
"""Hash a passphrase to bytes for reset token storage."""
|
||||||
|
if not passphrase_util.is_well_formed(passphrase):
|
||||||
|
raise ValueError(
|
||||||
|
"Trying to reset with a session token in place of a passphrase"
|
||||||
|
if len(passphrase) == 16
|
||||||
|
else "Invalid passphrase format"
|
||||||
|
)
|
||||||
|
return hashlib.sha512(passphrase.encode()).digest()[:9]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
|
||||||
|
"""Get a reset token by passphrase."""
|
||||||
|
key = cls.hash(passphrase)
|
||||||
|
return db.data().reset_tokens.get(key)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(
|
def create(
|
||||||
cls,
|
cls,
|
||||||
@@ -377,8 +494,8 @@ class ResetToken(msgspec.Struct, dict=True):
|
|||||||
code to give to the user.
|
code to give to the user.
|
||||||
"""
|
"""
|
||||||
if passphrase is None:
|
if passphrase is None:
|
||||||
passphrase = generate_passphrase()
|
passphrase = passphrase_util.generate()
|
||||||
key = hashlib.sha512(passphrase.encode()).digest()[:9]
|
key = cls.hash(passphrase)
|
||||||
user_uuid = user if isinstance(user, UUID) else user.uuid
|
user_uuid = user if isinstance(user, UUID) else user.uuid
|
||||||
token = cls(
|
token = cls(
|
||||||
user_uuid=user_uuid,
|
user_uuid=user_uuid,
|
||||||
@@ -416,6 +533,7 @@ class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
|
|||||||
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||||
"""In-memory database. Access fields directly for reads."""
|
"""In-memory database. Access fields directly for reads."""
|
||||||
|
|
||||||
|
config: Config
|
||||||
permissions: dict[UUID, Permission] = {}
|
permissions: dict[UUID, Permission] = {}
|
||||||
orgs: dict[UUID, Org] = {}
|
orgs: dict[UUID, Org] = {}
|
||||||
roles: dict[UUID, Role] = {}
|
roles: dict[UUID, Role] = {}
|
||||||
@@ -423,7 +541,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
credentials: dict[UUID, Credential] = {}
|
credentials: dict[UUID, Credential] = {}
|
||||||
sessions: dict[str, Session] = {}
|
sessions: dict[str, Session] = {}
|
||||||
reset_tokens: dict[bytes, ResetToken] = {}
|
reset_tokens: dict[bytes, ResetToken] = {}
|
||||||
config: Config = field(default_factory=lambda: Config(rp_id="localhost"))
|
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
# Store reference for persistence (not serialized)
|
# Store reference for persistence (not serialized)
|
||||||
@@ -466,7 +583,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Normalize host for comparison (stored hosts are already normalized)
|
# Normalize host for comparison (stored hosts are already normalized)
|
||||||
normalized_input = normalize_host(host)
|
normalized_input = hostutil.normalize_host(host)
|
||||||
|
|
||||||
# Validate host matches (sessions are always created with a host)
|
# Validate host matches (sessions are always created with a host)
|
||||||
if s.host != normalized_input:
|
if s.host != normalized_input:
|
||||||
|
|||||||
+15
-12
@@ -10,10 +10,10 @@ from uvicorn import Config as UvicornConfig
|
|||||||
from uvicorn import Server
|
from uvicorn import Server
|
||||||
from uvicorn import run as uvicorn_run
|
from uvicorn import run as uvicorn_run
|
||||||
|
|
||||||
|
from paskia import db
|
||||||
from paskia import globals as _globals
|
from paskia import globals as _globals
|
||||||
from paskia.bootstrap import bootstrap_if_needed
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
from paskia.config import PaskiaConfig
|
from paskia.config import PaskiaConfig
|
||||||
from paskia.db import get_config, set_config
|
|
||||||
from paskia.db import init as db_init
|
from paskia.db import init as db_init
|
||||||
from paskia.db.background import flush
|
from paskia.db.background import flush
|
||||||
from paskia.db.structs import Config
|
from paskia.db.structs import Config
|
||||||
@@ -107,7 +107,7 @@ def main():
|
|||||||
|
|
||||||
# Init db and load stored config
|
# Init db and load stored config
|
||||||
asyncio.run(db_init(rp_id=args.rp_id))
|
asyncio.run(db_init(rp_id=args.rp_id))
|
||||||
stored_config = get_config()
|
stored_config = db.data().config
|
||||||
|
|
||||||
# Apply defaults from stored config
|
# Apply defaults from stored config
|
||||||
if args.rp_name is None and stored_config.rp_name is not None:
|
if args.rp_name is None and stored_config.rp_name is not None:
|
||||||
@@ -199,15 +199,14 @@ def main():
|
|||||||
|
|
||||||
startupbox.print_startup_config(config)
|
startupbox.print_startup_config(config)
|
||||||
|
|
||||||
if args.save:
|
# Build config to save (for bootstrap or explicit --save)
|
||||||
new_config = Config(
|
cli_config = Config(
|
||||||
rp_id=args.rp_id,
|
rp_id=args.rp_id,
|
||||||
rp_name=args.rp_name,
|
rp_name=args.rp_name,
|
||||||
origins=args.origins,
|
origins=args.origins,
|
||||||
auth_host=args.auth_host,
|
auth_host=args.auth_host,
|
||||||
listen=args.listen,
|
listen=args.listen,
|
||||||
)
|
)
|
||||||
asyncio.run(set_config(new_config))
|
|
||||||
|
|
||||||
run_kwargs: dict = {
|
run_kwargs: dict = {
|
||||||
"log_level": "warning", # Suppress startup messages; we use custom logging
|
"log_level": "warning", # Suppress startup messages; we use custom logging
|
||||||
@@ -229,7 +228,11 @@ def main():
|
|||||||
origins=config.origins,
|
origins=config.origins,
|
||||||
bootstrap=False,
|
bootstrap=False,
|
||||||
)
|
)
|
||||||
await bootstrap_if_needed()
|
# Pass config to bootstrap - it will be saved within the bootstrap transaction
|
||||||
|
await bootstrap_if_needed(config=cli_config)
|
||||||
|
# Also save config if --save was explicitly used (even without bootstrap)
|
||||||
|
if args.save:
|
||||||
|
await db.update_config(cli_config)
|
||||||
await flush()
|
await flush()
|
||||||
|
|
||||||
if len(endpoints) > 1:
|
if len(endpoints) > 1:
|
||||||
|
|||||||
+28
-28
@@ -83,7 +83,6 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
|||||||
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||||
|
|
||||||
def org_to_dict(o):
|
def org_to_dict(o):
|
||||||
users = db.get_organization_users(o.uuid)
|
|
||||||
return {
|
return {
|
||||||
"uuid": o.uuid,
|
"uuid": o.uuid,
|
||||||
"display_name": o.display_name,
|
"display_name": o.display_name,
|
||||||
@@ -101,12 +100,13 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
|||||||
{
|
{
|
||||||
"uuid": u.uuid,
|
"uuid": u.uuid,
|
||||||
"display_name": u.display_name,
|
"display_name": u.display_name,
|
||||||
"role": role_name,
|
"role": r.display_name,
|
||||||
"role_uuid": u.role_uuid,
|
"role_uuid": u.role_uuid,
|
||||||
"visits": u.visits,
|
"visits": u.visits,
|
||||||
"last_seen": u.last_seen,
|
"last_seen": u.last_seen,
|
||||||
}
|
}
|
||||||
for (u, role_name) in users
|
for r in o.roles
|
||||||
|
for u in r.users
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,8 +462,8 @@ async def admin_update_user_role(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, _current_role = db.get_user_organization(user_uuid)
|
user = db.data().users[user_uuid]
|
||||||
except ValueError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -471,7 +471,7 @@ async def admin_update_user_role(
|
|||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
if not can_manage_org(ctx, user_org.uuid):
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
)
|
)
|
||||||
@@ -483,7 +483,7 @@ async def admin_update_user_role(
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
raise ValueError("Invalid role UUID")
|
raise ValueError("Invalid role UUID")
|
||||||
new_role = db.data().roles.get(new_role_uuid)
|
new_role = db.data().roles.get(new_role_uuid)
|
||||||
if not new_role or new_role.org_uuid != user_org.uuid:
|
if not new_role or new_role.org_uuid != user.org.uuid:
|
||||||
raise ValueError("Role not found in organization")
|
raise ValueError("Role not found in organization")
|
||||||
|
|
||||||
# Sanity check: prevent admin from removing their own access
|
# Sanity check: prevent admin from removing their own access
|
||||||
@@ -511,8 +511,8 @@ async def admin_create_user_registration_link(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
user = db.data().users[user_uuid]
|
||||||
except ValueError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -521,13 +521,13 @@ async def admin_create_user_registration_link(
|
|||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
max_age="5m",
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if not can_manage_org(ctx, user_org.uuid):
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if user has existing credentials
|
# Check if user has existing credentials
|
||||||
has_credentials = db.get_user_credential_ids(user_uuid)
|
has_credentials = db.data().users[user_uuid].credential_ids
|
||||||
token_type = "user registration" if not has_credentials else "account recovery"
|
token_type = "user registration" if not has_credentials else "account recovery"
|
||||||
|
|
||||||
expiry = reset_expires()
|
expiry = reset_expires()
|
||||||
@@ -552,8 +552,9 @@ async def admin_get_user_detail(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, role_name = db.get_user_organization(user_uuid)
|
user = db.data().users[user_uuid]
|
||||||
except ValueError:
|
role_name = user.role.display_name
|
||||||
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -561,17 +562,16 @@ async def admin_get_user_detail(
|
|||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
if not can_manage_org(ctx, user_org.uuid):
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
)
|
)
|
||||||
user = db.data().users.get(user_uuid)
|
|
||||||
normalized_host = hostutil.normalize_host(request.headers.get("host"))
|
normalized_host = hostutil.normalize_host(request.headers.get("host"))
|
||||||
|
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
{
|
{
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"org": {"display_name": user_org.display_name},
|
"org": {"display_name": user.org.display_name},
|
||||||
"role": role_name,
|
"role": role_name,
|
||||||
"visits": user.visits,
|
"visits": user.visits,
|
||||||
"created_at": user.created_at,
|
"created_at": user.created_at,
|
||||||
@@ -609,8 +609,8 @@ async def admin_update_user_display_name(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
user = db.data().users[user_uuid]
|
||||||
except ValueError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -618,7 +618,7 @@ async def admin_update_user_display_name(
|
|||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
if not can_manage_org(ctx, user_org.uuid):
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
)
|
)
|
||||||
@@ -639,8 +639,8 @@ async def admin_delete_user(
|
|||||||
):
|
):
|
||||||
"""Delete a user and all their credentials/sessions."""
|
"""Delete a user and all their credentials/sessions."""
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
user = db.data().users[user_uuid]
|
||||||
except ValueError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -649,7 +649,7 @@ async def admin_delete_user(
|
|||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
max_age="5m",
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if not can_manage_org(ctx, user_org.uuid):
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
)
|
)
|
||||||
@@ -668,8 +668,8 @@ async def admin_delete_user_credential(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
user = db.data().users[user_uuid]
|
||||||
except ValueError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -678,7 +678,7 @@ async def admin_delete_user_credential(
|
|||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
max_age="5m",
|
max_age="5m",
|
||||||
)
|
)
|
||||||
if not can_manage_org(ctx, user_org.uuid):
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
)
|
)
|
||||||
@@ -694,8 +694,8 @@ async def admin_delete_user_session(
|
|||||||
auth=AUTH_COOKIE,
|
auth=AUTH_COOKIE,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
user = db.data().users[user_uuid]
|
||||||
except ValueError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -703,7 +703,7 @@ async def admin_delete_user_session(
|
|||||||
match=permutil.has_any,
|
match=permutil.has_any,
|
||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
)
|
)
|
||||||
if not can_manage_org(ctx, user_org.uuid):
|
if not can_manage_org(ctx, user.org.uuid):
|
||||||
raise authz.AuthException(
|
raise authz.AuthException(
|
||||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async def websocket_register_add(
|
|||||||
stripped = name.strip()
|
stripped = name.strip()
|
||||||
if stripped:
|
if stripped:
|
||||||
user_name = stripped
|
user_name = stripped
|
||||||
credential_ids = db.get_user_credential_ids(user_uuid) or None
|
credential_ids = user.credential_ids or None
|
||||||
|
|
||||||
# WebAuthn registration
|
# WebAuthn registration
|
||||||
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
|
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from uuid import UUID
|
|||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.authsession import expires
|
|
||||||
from paskia.db import Credential, SessionContext
|
from paskia.db import Credential, SessionContext
|
||||||
from paskia.fastapi.session import infodict
|
from paskia.fastapi.session import infodict
|
||||||
from paskia.fastapi.wsutil import validate_origin
|
from paskia.fastapi.wsutil import validate_origin
|
||||||
@@ -93,7 +92,7 @@ async def authenticate_and_login(
|
|||||||
if auth:
|
if auth:
|
||||||
existing_ctx = db.data().session_ctx(auth, host)
|
existing_ctx = db.data().session_ctx(auth, host)
|
||||||
if existing_ctx:
|
if existing_ctx:
|
||||||
credential_ids = db.get_user_credential_ids(existing_ctx.user.uuid) or None
|
credential_ids = existing_ctx.user.credential_ids or None
|
||||||
|
|
||||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||||
|
|
||||||
@@ -105,7 +104,6 @@ async def authenticate_and_login(
|
|||||||
host=normalized_host,
|
host=normalized_host,
|
||||||
ip=metadata["ip"],
|
ip=metadata["ip"],
|
||||||
user_agent=metadata["user_agent"],
|
user_agent=metadata["user_agent"],
|
||||||
expiry=expires(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch and return the full session context
|
# Fetch and return the full session context
|
||||||
|
|||||||
+5
-5
@@ -21,13 +21,15 @@ import pytest_asyncio
|
|||||||
|
|
||||||
import paskia.db.operations as ops_db
|
import paskia.db.operations as ops_db
|
||||||
from paskia import globals as paskia_globals
|
from paskia import globals as paskia_globals
|
||||||
from paskia.authsession import expires, reset_expires
|
from paskia.authsession import reset_expires
|
||||||
from paskia.db import (
|
from paskia.db import (
|
||||||
|
Config,
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
Permission,
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
User,
|
User,
|
||||||
|
bootstrap,
|
||||||
create_credential,
|
create_credential,
|
||||||
create_reset_token,
|
create_reset_token,
|
||||||
create_role,
|
create_role,
|
||||||
@@ -60,14 +62,14 @@ async def test_db() -> AsyncGenerator[DB, None]:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||||
db = DB()
|
db = DB(config=Config(rp_id="test.example.com"))
|
||||||
store = JsonlStore(db, f.name)
|
store = JsonlStore(db, f.name)
|
||||||
db._store = store
|
db._store = store
|
||||||
await store.load()
|
await store.load()
|
||||||
ops_db._db = db
|
ops_db._db = db
|
||||||
ops_db._store = store
|
ops_db._store = store
|
||||||
# Bootstrap creates the initial permissions, org, role, and admin user
|
# Bootstrap creates the initial permissions, org, role, and admin user
|
||||||
ops_db.bootstrap(
|
bootstrap(
|
||||||
org_name="Test Organization",
|
org_name="Test Organization",
|
||||||
admin_name="Test Admin",
|
admin_name="Test Admin",
|
||||||
)
|
)
|
||||||
@@ -183,7 +185,6 @@ async def session_token(
|
|||||||
host="localhost",
|
host="localhost",
|
||||||
ip="127.0.0.1",
|
ip="127.0.0.1",
|
||||||
user_agent="pytest",
|
user_agent="pytest",
|
||||||
expiry=expires(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -198,7 +199,6 @@ async def regular_session_token(
|
|||||||
host="localhost",
|
host="localhost",
|
||||||
ip="127.0.0.1",
|
ip="127.0.0.1",
|
||||||
user_agent="pytest",
|
user_agent="pytest",
|
||||||
expiry=expires(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import pytest_asyncio
|
|||||||
import uuid7
|
import uuid7
|
||||||
|
|
||||||
from paskia import db
|
from paskia import db
|
||||||
from paskia.authsession import expires
|
|
||||||
from paskia.db import (
|
from paskia.db import (
|
||||||
Credential,
|
Credential,
|
||||||
Org,
|
Org,
|
||||||
@@ -104,7 +103,6 @@ async def second_org_session_token(
|
|||||||
host="localhost",
|
host="localhost",
|
||||||
ip="127.0.0.1",
|
ip="127.0.0.1",
|
||||||
user_agent="pytest",
|
user_agent="pytest",
|
||||||
expiry=expires(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -161,7 +159,6 @@ async def org_admin_session_token(
|
|||||||
host="localhost",
|
host="localhost",
|
||||||
ip="127.0.0.1",
|
ip="127.0.0.1",
|
||||||
user_agent="pytest",
|
user_agent="pytest",
|
||||||
expiry=expires(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1299,7 +1296,6 @@ class TestAdminSessions:
|
|||||||
host="other.host:4401",
|
host="other.host:4401",
|
||||||
ip="192.168.1.1",
|
ip="192.168.1.1",
|
||||||
user_agent="other-agent",
|
user_agent="other-agent",
|
||||||
expiry=expires(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
|
|||||||
+3
-4
@@ -11,7 +11,7 @@ These tests cover:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -521,15 +521,14 @@ class TestValidateSessionRefresh:
|
|||||||
):
|
):
|
||||||
"""Validate should return 401 if session disappears during refresh."""
|
"""Validate should return 401 if session disappears during refresh."""
|
||||||
|
|
||||||
# Create a session with an old expiry time to trigger refresh
|
# Create a session with a short remaining duration to trigger refresh
|
||||||
old_expiry = datetime.now(UTC) + EXPIRES - timedelta(minutes=10)
|
|
||||||
token = create_session(
|
token = create_session(
|
||||||
user_uuid=test_user.uuid,
|
user_uuid=test_user.uuid,
|
||||||
credential_uuid=test_credential.uuid,
|
credential_uuid=test_credential.uuid,
|
||||||
host="localhost",
|
host="localhost",
|
||||||
ip="127.0.0.1",
|
ip="127.0.0.1",
|
||||||
user_agent="pytest",
|
user_agent="pytest",
|
||||||
expiry=old_expiry,
|
duration=EXPIRES - timedelta(minutes=10),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete the session right before validate tries to refresh
|
# Delete the session right before validate tries to refresh
|
||||||
|
|||||||
Reference in New Issue
Block a user