Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f50c8c20d | ||
|
|
816c7a681e | ||
|
|
d31c09084e | ||
|
|
cc938dd306 | ||
|
|
36db1e7e56 | ||
|
|
95c163e37a | ||
|
|
2d0d17c307 | ||
|
|
10980ad39b | ||
|
|
42b54cf645 | ||
|
|
232d0e1ae0 | ||
|
|
e97a2b3291 | ||
|
|
cde709e252 | ||
|
|
72d76df35d | ||
|
|
1a742fc0e7 | ||
|
|
0b29654d6f | ||
|
|
76f24a755b | ||
|
|
5c452f325a | ||
|
|
e9b6bc7a3d | ||
|
|
f5545b48f0 | ||
|
|
c1b2bcf76c | ||
|
|
1806bcab5c | ||
|
|
be177cbafc |
@@ -6,6 +6,7 @@ dist/
|
||||
package-lock.json
|
||||
paskia.sqlite
|
||||
*.paskiadb
|
||||
*.data
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
coverage-html/
|
||||
|
||||
@@ -66,7 +66,7 @@ paskia [options]
|
||||
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
||||
| --save | Save current options to database | (only --rp-id required on further invocations) |
|
||||
|
||||
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||
|
||||
## Tutorial: From Local Testing to Production
|
||||
|
||||
|
||||
+8
-2
@@ -26,13 +26,17 @@ The `validate` and `forward` endpoints take query arguments `perm=` and `max_age
|
||||
|
||||
| Method | Path | Used for | Notes |
|
||||
|---:|---|---|---|
|
||||
| PUT | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` |
|
||||
| PATCH | `/auth/api/user/display-name` | Update the user’s display name | Body: JSON `{ "display_name": "..." }` |
|
||||
| GET | `/auth/api/user/{uuid}/profile.webp` | Canonical avatar image URL | Public on the auth host; serves `image/webp` with `ETag` and short-lived cache headers |
|
||||
| PUT | `/auth/api/user/{uuid}/profile.webp` | Upload or replace a user avatar | Multipart form with `file`; upload must already be square WebP prepared in the browser |
|
||||
| DELETE | `/auth/api/user/{uuid}/profile.webp` | Remove a user avatar | Allowed for the user, master admin, or org admin for users in the same org |
|
||||
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
|
||||
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
|
||||
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
|
||||
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
|
||||
|
||||
These are used mostly from the user profile panel and modify the current user.
|
||||
These are used mostly from the user profile panel. The avatar route is also used by admins when managing other users.
|
||||
`GET /auth/api/user-info` includes `user.avatar_url` when the user has an uploaded avatar, using the same canonical `/auth/api/user/{uuid}/profile.webp` path.
|
||||
|
||||
### Admin API: `/auth/api/admin/*`
|
||||
|
||||
@@ -72,6 +76,8 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
||||
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
|
||||
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
|
||||
|
||||
Admins edit user avatars through the same canonical `/auth/api/user/{uuid}/profile.webp` PUT and DELETE endpoints.
|
||||
|
||||
### WebSockets: `/auth/ws/*`
|
||||
|
||||
| Path | Used for | Notes |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execSync, spawn } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -58,6 +58,11 @@ export default async function globalSetup() {
|
||||
// Use a fresh database file for tests
|
||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing stale test database...')
|
||||
rmSync(testDbFile, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
// Start the server using Node's spawn
|
||||
const serverProcess = spawn('uv', serverArgs, {
|
||||
cwd: projectRoot,
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function globalTeardown() {
|
||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing test database...')
|
||||
rmSync(testDbFile)
|
||||
rmSync(testDbFile, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
// Generate Python coverage report if coverage was collected
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, SessionValidator } from 'paskia'
|
||||
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
@@ -72,8 +72,8 @@ async function loadUserInfo() {
|
||||
// apiJson handles 401/403 with auth.iframe automatically:
|
||||
// shows overlay iframe, waits for auth, retries the request.
|
||||
const [validateData, userInfoData] = await Promise.all([
|
||||
apiJson('/auth/api/validate', { method: 'POST' }),
|
||||
apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
|
||||
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||
])
|
||||
store.userInfo = userInfoData
|
||||
store.ctx = validateData.ctx
|
||||
|
||||
@@ -13,7 +13,7 @@ import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
|
||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { apiJson, SessionValidator } from 'paskia'
|
||||
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import { uuidv7 } from 'uuidv7'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
@@ -196,7 +196,7 @@ function orgUserCount(org) {
|
||||
}
|
||||
|
||||
async function loadUserInfo() {
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
info.value = data
|
||||
updateThemeFromSession(data.ctx)
|
||||
authenticated.value = true
|
||||
@@ -796,7 +796,7 @@ async function submitDialog() {
|
||||
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update role', 'error')
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
const status = reactive({
|
||||
@@ -164,7 +164,8 @@ async function exchangeCode(result) {
|
||||
}
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
|
||||
timeout: paskiaSettings.auth_ms,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -36,14 +37,26 @@ const orgPermissions = computed(() => {
|
||||
})
|
||||
|
||||
// Get users for a role as sorted array of { uuid, ...user }
|
||||
function getNormalizedName(name) {
|
||||
let cleaned = name.replace(/\([^)]*\)/g, '').trim();
|
||||
if (cleaned.includes(',')) {
|
||||
return cleaned.toLowerCase();
|
||||
} else {
|
||||
const parts = cleaned.split(/\s+/);
|
||||
const last = parts.pop();
|
||||
const first = parts.join(' ');
|
||||
return `${last}, ${first}`.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function roleUsers(roleUuid) {
|
||||
return Object.entries(props.selectedOrg.users)
|
||||
.filter(([_, u]) => u.role === roleUuid)
|
||||
.map(([uuid, u]) => ({ uuid, ...u }))
|
||||
.sort((a, b) => {
|
||||
const nameA = a.display_name.toLowerCase()
|
||||
const nameB = b.display_name.toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
const normA = getNormalizedName(a.display_name);
|
||||
const normB = getNormalizedName(b.display_name);
|
||||
return normA.localeCompare(normB);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,10 +72,6 @@ function onUserChange(evt, targetRoleUuid) {
|
||||
}
|
||||
}
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
}
|
||||
|
||||
function toggleRolePermission(role, pid, checked) {
|
||||
emit('toggleRolePermission', role, pid, checked)
|
||||
}
|
||||
@@ -388,8 +397,19 @@ defineExpose({ focusFirstElement })
|
||||
@keydown.enter="$emit('openUser', u)"
|
||||
:title="u.uuid"
|
||||
>
|
||||
<ProfilePicture
|
||||
class="user-chip-picture"
|
||||
:src="u.avatar_url"
|
||||
:title="u.display_name"
|
||||
width="3.25rem"
|
||||
height="100%"
|
||||
radius="0"
|
||||
fallback-size="1.3rem"
|
||||
/>
|
||||
<span class="user-chip-body">
|
||||
<span class="name">{{ u.display_name }}</span>
|
||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
@@ -409,8 +429,8 @@ defineExpose({ focusFirstElement })
|
||||
.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 .add-role-head { cursor: pointer; }
|
||||
.roles-grid { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||
.role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||
.roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||
.role-column { flex: 0 0 17em; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||
.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-actions { display: flex; gap: var(--space-xs); }
|
||||
@@ -418,9 +438,12 @@ defineExpose({ focusFirstElement })
|
||||
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
||||
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
||||
.user-chip { background: var(--color-accent-strong); color: white; border: none; 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-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); align-items: stretch; gap: 0; cursor: grab; overflow: hidden; min-height: 3.25rem; }
|
||||
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||
.user-chip .meta { font-size: 0.7rem; color: rgba(255, 255, 255, 0.8); }
|
||||
.user-chip-picture { align-self: stretch; }
|
||||
.user-chip-body { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 0.1rem; padding: 0.45rem 0.6rem; }
|
||||
.user-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.user-chip .meta { font-size: 0.7rem; opacity: 0.85; }
|
||||
.user-chip.sortable-ghost { opacity: 0.5; }
|
||||
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
|
||||
@@ -15,14 +15,11 @@ const props = defineProps({
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgSection = ref(null)
|
||||
const orgActionsRef = ref(null)
|
||||
const orgTableRef = ref(null)
|
||||
const permMatrixRef = ref(null)
|
||||
const permActionsRef = ref(null)
|
||||
const permTableRef = ref(null)
|
||||
const oidcActionsRef = ref(null)
|
||||
const oidcTableRef = ref(null)
|
||||
|
||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
|
||||
@@ -62,10 +59,6 @@ const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.s
|
||||
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
|
||||
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
}
|
||||
|
||||
function getRoleNames(org) {
|
||||
// org.roles is dict[UUID, Role]
|
||||
return Object.values(org.roles)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -23,6 +24,8 @@ const authStore = useAuthStore()
|
||||
const terminatingSessions = ref({})
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
const hoveredSession = ref(null)
|
||||
const showPictureDialog = ref(false)
|
||||
const avatarRenderVersion = ref(0)
|
||||
|
||||
// Convert credentials dict to array with uuid attached as 'credential'
|
||||
const credentials = computed(() =>
|
||||
@@ -48,6 +51,20 @@ function handleEditName() {
|
||||
emit('editUserName', props.selectedUser)
|
||||
}
|
||||
|
||||
function openPictureDialog() {
|
||||
if (!props.userDetail || props.userDetail.error) return
|
||||
showPictureDialog.value = true
|
||||
}
|
||||
|
||||
function closePictureDialog() {
|
||||
showPictureDialog.value = false
|
||||
}
|
||||
|
||||
function handlePictureUpdated() {
|
||||
avatarRenderVersion.value += 1
|
||||
emit('refreshUserDetail')
|
||||
}
|
||||
|
||||
async function handleDelete(credential) {
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||
@@ -102,7 +119,7 @@ function handleUserInfoKeydown(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' })
|
||||
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||
} else if (direction === 'up') {
|
||||
emit('navigateOut', 'up')
|
||||
} else if (direction === 'down') {
|
||||
@@ -124,7 +141,7 @@ function handleRegActionsKeydown(event) {
|
||||
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
|
||||
} else if (direction === 'up') {
|
||||
// Move to user info edit button
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||
} else if (direction === 'down') {
|
||||
// Move to credential list
|
||||
credentialListRef.value?.$el?.focus()
|
||||
@@ -174,10 +191,22 @@ function handleBackButtonKeydown(event) {
|
||||
|
||||
// Focus helper for external navigation
|
||||
function focusFirstElement() {
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' })
|
||||
focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
|
||||
}
|
||||
|
||||
defineExpose({ focusFirstElement })
|
||||
|
||||
const currentPictureEndpoint = computed(() => {
|
||||
if (!props.selectedUser?.uuid) return null
|
||||
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
|
||||
})
|
||||
|
||||
const adminPictureTitle = computed(() => {
|
||||
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
|
||||
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
|
||||
const label = username || displayName || 'User'
|
||||
return `Profile Picture for ${label}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -186,6 +215,9 @@ defineExpose({ focusFirstElement })
|
||||
<UserBasicInfo
|
||||
v-if="userDetail && !userDetail.error"
|
||||
:name="userDetail.user.display_name || selectedUser.display_name"
|
||||
:avatar-url="userDetail.user.avatar_url"
|
||||
:avatar-render-version="avatarRenderVersion"
|
||||
avatar-clickable
|
||||
:visits="userDetail.user.visits"
|
||||
:created-at="userDetail.user.created_at"
|
||||
:last-seen="userDetail.user.last_seen"
|
||||
@@ -196,6 +228,7 @@ defineExpose({ focusFirstElement })
|
||||
:role-name="userDetail.role.display_name"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@avatar-click="openPictureDialog"
|
||||
@edit="handleEditName"
|
||||
>
|
||||
<div class="admin-actions">
|
||||
@@ -258,6 +291,15 @@ defineExpose({ focusFirstElement })
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
/>
|
||||
<ProfilePictureEditorModal
|
||||
v-if="showPictureDialog && currentPictureEndpoint"
|
||||
:endpoint="currentPictureEndpoint"
|
||||
:picture-url="userDetail?.user?.avatar_url"
|
||||
:render-version="avatarRenderVersion"
|
||||
:title="adminPictureTitle"
|
||||
@close="closePictureDialog"
|
||||
@updated="handlePictureUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340">
|
||||
<path fill="#DDD" d="m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1
|
||||
1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 220 B |
@@ -9,8 +9,8 @@
|
||||
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
--color-canvas: white;
|
||||
--color-surface: white;
|
||||
--color-surface-subtle: white;
|
||||
--color-surface: #def;
|
||||
--color-surface-subtle: #bcf;
|
||||
--color-surface-hover: oklab(0.97 -0.01 -0.02);
|
||||
--color-dialog: oklab(0.96 -0.01 -0.03);
|
||||
--color-border: oklab(0.82 -0.02 -0.06);
|
||||
@@ -21,7 +21,7 @@
|
||||
--color-link: oklab(0.5 -0.06 -0.17);
|
||||
--color-link-hover: oklab(0.45 -0.06 -0.19);
|
||||
--color-accent: oklab(0.55 -0.06 -0.19);
|
||||
--color-accent-strong: oklab(0.45 -0.06 -0.19);
|
||||
--color-accent-strong: #46f;
|
||||
--color-accent-contrast: white;
|
||||
--color-secondary: oklab(0.55 -0.02 -0.05);
|
||||
--color-secondary-strong: oklab(0.45 -0.02 -0.05);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<UserBasicInfo
|
||||
v-if="ctx"
|
||||
:name="ctx.user.display_name"
|
||||
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||
:visits="authStore.userInfo.user.visits"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="dialog-overlay" @click="$emit('close')">
|
||||
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
|
||||
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,7 +17,9 @@ const props = defineProps({
|
||||
// Optional: index to help find next sibling when item is deleted
|
||||
focusIndex: { type: Number, default: -1 },
|
||||
// Optional: selector for finding siblings when restoring focus
|
||||
focusSiblingSelector: { type: String, default: '' }
|
||||
focusSiblingSelector: { type: String, default: '' },
|
||||
// Optional: extra class name(s) for the modal panel
|
||||
panelClass: { type: [String, Array, Object], default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<component
|
||||
:is="rootTag"
|
||||
v-bind="rootAttrs"
|
||||
class="profile-picture"
|
||||
:class="{ 'profile-picture-btn': clickable }"
|
||||
:style="pictureStyle"
|
||||
@click="handleClick"
|
||||
>
|
||||
<img
|
||||
v-if="showPicture"
|
||||
:key="`${src || 'none'}:${renderVersion}`"
|
||||
:src="src"
|
||||
alt=""
|
||||
class="profile-picture-image"
|
||||
@error="handleError"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="profileGeneric"
|
||||
alt=""
|
||||
class="profile-picture-fallback"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import profileGeneric from '@/assets/profile-generic.svg'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
src: { type: String, default: null },
|
||||
clickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
renderVersion: { type: [Number, String], default: 0 },
|
||||
width: { type: String, default: '3rem' },
|
||||
height: { type: String, default: '3rem' },
|
||||
radius: { type: String, default: '0.9rem' },
|
||||
fit: { type: String, default: 'cover' },
|
||||
filter: { type: String, default: 'none' },
|
||||
fallbackSize: { type: String, default: '2em' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
const pictureAvailable = ref(true)
|
||||
|
||||
const rootTag = computed(() => (props.clickable ? 'button' : 'div'))
|
||||
const showPicture = computed(() => !!props.src && pictureAvailable.value)
|
||||
const pictureStyle = computed(() => ({
|
||||
'--profile-picture-width': props.width,
|
||||
'--profile-picture-height': props.height,
|
||||
'--profile-picture-radius': props.radius,
|
||||
'--profile-picture-fit': props.fit,
|
||||
'--profile-picture-filter': props.filter,
|
||||
'--profile-picture-fallback-size': props.fallbackSize
|
||||
}))
|
||||
const rootAttrs = computed(() => {
|
||||
if (!props.clickable) return { title: props.title || undefined }
|
||||
return {
|
||||
type: 'button',
|
||||
disabled: props.loading,
|
||||
title: props.title || undefined
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.src, () => {
|
||||
pictureAvailable.value = true
|
||||
})
|
||||
|
||||
const handleError = () => {
|
||||
pictureAvailable.value = false
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.clickable || props.loading) return
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-picture {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--profile-picture-width);
|
||||
height: var(--profile-picture-height);
|
||||
font-size: var(--profile-picture-fallback-size);
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--profile-picture-radius);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-picture-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile-picture-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
.profile-picture-btn:disabled {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.profile-picture-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--profile-picture-fit);
|
||||
display: block;
|
||||
filter: var(--profile-picture-filter);
|
||||
}
|
||||
|
||||
.profile-picture-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<Modal panel-class="modal-panel--avatar" @close="closeEditor">
|
||||
<h3>{{ title }}</h3>
|
||||
<input
|
||||
ref="pictureInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="profile-picture-editor-input"
|
||||
:disabled="saving"
|
||||
@change="handlePictureSelected"
|
||||
/>
|
||||
<div ref="picturePreview" class="profile-picture-editor-preview" :style="previewStyle">
|
||||
<img
|
||||
v-if="editorImageUrl && displayMetrics"
|
||||
:src="editorImageUrl"
|
||||
alt=""
|
||||
class="profile-picture-editor-image"
|
||||
:style="editorImageStyle"
|
||||
/>
|
||||
<img
|
||||
v-if="editorImageUrl && displayMetrics"
|
||||
:src="editorImageUrl"
|
||||
alt=""
|
||||
class="profile-picture-editor-image profile-picture-editor-image--overlay"
|
||||
:style="editorOverlayStyle"
|
||||
/>
|
||||
<div
|
||||
v-if="editorImageUrl && displayMetrics"
|
||||
class="profile-picture-editor-crop"
|
||||
:style="cropBoxStyle"
|
||||
@pointerdown="startMove"
|
||||
>
|
||||
<div class="profile-picture-editor-guides" aria-hidden="true">
|
||||
<div class="profile-picture-editor-guide profile-picture-editor-guide--circle"></div>
|
||||
<div class="profile-picture-editor-guide profile-picture-editor-guide--eyes"></div>
|
||||
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-left"></div>
|
||||
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-right"></div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-picture-editor-handle profile-picture-editor-handle--nw"
|
||||
@pointerdown.stop="startResize($event, 'nw')"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-picture-editor-handle profile-picture-editor-handle--ne"
|
||||
@pointerdown.stop="startResize($event, 'ne')"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-picture-editor-handle profile-picture-editor-handle--sw"
|
||||
@pointerdown.stop="startResize($event, 'sw')"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="profile-picture-editor-handle profile-picture-editor-handle--se"
|
||||
@pointerdown.stop="startResize($event, 'se')"
|
||||
></button>
|
||||
</div>
|
||||
<ProfilePicture
|
||||
v-else
|
||||
class="profile-picture-editor-trigger"
|
||||
:src="pictureUrl"
|
||||
:render-version="renderVersion"
|
||||
clickable
|
||||
:loading="saving"
|
||||
title="Choose profile picture"
|
||||
width="100%"
|
||||
height="100%"
|
||||
radius="0"
|
||||
fit="contain"
|
||||
fallback-size="5rem"
|
||||
@click="triggerPictureSelect"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="errorMessage" class="error small">{{ errorMessage }}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" :disabled="saving" @click="closeEditor">Back</button>
|
||||
<button
|
||||
v-if="!editorImageUrl && pictureUrl"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="saving"
|
||||
@click="removePicture"
|
||||
>Delete</button>
|
||||
<button
|
||||
v-if="editorImageUrl"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="saving"
|
||||
@click="savePicture"
|
||||
>Save</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { apiJson } from 'paskia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
|
||||
const AVATAR_UPLOAD_SIZE = 720
|
||||
const MIN_CROP_SIZE = 36
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true },
|
||||
pictureUrl: { type: String, default: null },
|
||||
renderVersion: { type: [Number, String], default: 0 },
|
||||
title: { type: String, default: 'Profile Picture' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'updated'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const pictureInput = ref(null)
|
||||
const picturePreview = ref(null)
|
||||
const editorImage = ref(null)
|
||||
const editorImageUrl = ref('')
|
||||
const previewObjectUrl = ref(null)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const cropRect = reactive({ x: 0, y: 0, size: 0 })
|
||||
const previewRect = reactive({ width: 0, height: 0 })
|
||||
const viewportSize = reactive({ width: 0, height: 0 })
|
||||
let dragState = null
|
||||
let previewObserver = null
|
||||
|
||||
onMounted(async () => {
|
||||
viewportSize.width = window.innerWidth
|
||||
viewportSize.height = window.innerHeight
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', endPointerInteraction)
|
||||
window.addEventListener('resize', syncPreviewRect)
|
||||
await nextTick()
|
||||
syncPreviewRect()
|
||||
if (picturePreview.value && typeof ResizeObserver !== 'undefined') {
|
||||
previewObserver = new ResizeObserver(() => syncPreviewRect())
|
||||
previewObserver.observe(picturePreview.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', endPointerInteraction)
|
||||
window.removeEventListener('resize', syncPreviewRect)
|
||||
previewObserver?.disconnect()
|
||||
clearPreviewObjectUrl()
|
||||
})
|
||||
|
||||
watch(editorImage, async (image) => {
|
||||
if (!image) return
|
||||
await nextTick()
|
||||
syncPreviewRect()
|
||||
initializeCrop()
|
||||
})
|
||||
|
||||
const clearPreviewObjectUrl = () => {
|
||||
if (!previewObjectUrl.value) return
|
||||
URL.revokeObjectURL(previewObjectUrl.value)
|
||||
previewObjectUrl.value = null
|
||||
}
|
||||
|
||||
const resetEditor = () => {
|
||||
clearPreviewObjectUrl()
|
||||
editorImage.value = null
|
||||
editorImageUrl.value = ''
|
||||
cropRect.x = 0
|
||||
cropRect.y = 0
|
||||
cropRect.size = 0
|
||||
errorMessage.value = ''
|
||||
if (pictureInput.value) pictureInput.value.value = ''
|
||||
}
|
||||
|
||||
const syncPreviewRect = () => {
|
||||
viewportSize.width = window.innerWidth
|
||||
viewportSize.height = window.innerHeight
|
||||
const element = picturePreview.value
|
||||
if (!element) return
|
||||
previewRect.width = element.clientWidth
|
||||
previewRect.height = element.clientHeight
|
||||
}
|
||||
|
||||
const previewStyle = computed(() => {
|
||||
const image = editorImage.value
|
||||
if (!image) {
|
||||
const size = Math.min(viewportSize.width * 0.72, viewportSize.height * 0.42, 352)
|
||||
return {
|
||||
width: `${Math.max(160, Math.round(size))}px`,
|
||||
height: `${Math.max(160, Math.round(size))}px`
|
||||
}
|
||||
}
|
||||
|
||||
const maxWidth = Math.min(viewportSize.width * 0.88, 928)
|
||||
const maxHeight = Math.min(viewportSize.height * 0.62, 620)
|
||||
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight)
|
||||
|
||||
return {
|
||||
width: `${Math.max(1, Math.round(image.naturalWidth * scale))}px`,
|
||||
height: `${Math.max(1, Math.round(image.naturalHeight * scale))}px`
|
||||
}
|
||||
})
|
||||
|
||||
const displayMetrics = computed(() => {
|
||||
const image = editorImage.value
|
||||
if (!image || !previewRect.width || !previewRect.height) return null
|
||||
const scale = Math.min(previewRect.width / image.naturalWidth, previewRect.height / image.naturalHeight)
|
||||
const width = image.naturalWidth * scale
|
||||
const height = image.naturalHeight * scale
|
||||
return {
|
||||
x: (previewRect.width - width) / 2,
|
||||
y: (previewRect.height - height) / 2,
|
||||
width,
|
||||
height
|
||||
}
|
||||
})
|
||||
|
||||
const editorImageStyle = computed(() => {
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics) return null
|
||||
return {
|
||||
width: `${metrics.width}px`,
|
||||
height: `${metrics.height}px`,
|
||||
left: `${metrics.x}px`,
|
||||
top: `${metrics.y}px`
|
||||
}
|
||||
})
|
||||
|
||||
const editorOverlayStyle = computed(() => {
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics || !cropRect.size) return editorImageStyle.value
|
||||
|
||||
const left = cropRect.x
|
||||
const top = cropRect.y
|
||||
const right = cropRect.x + cropRect.size
|
||||
const bottom = cropRect.y + cropRect.size
|
||||
|
||||
return {
|
||||
...editorImageStyle.value,
|
||||
clipPath: `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px)`
|
||||
}
|
||||
})
|
||||
|
||||
const cropBoxStyle = computed(() => {
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics || !cropRect.size) return null
|
||||
return {
|
||||
left: `${metrics.x + cropRect.x}px`,
|
||||
top: `${metrics.y + cropRect.y}px`,
|
||||
width: `${cropRect.size}px`,
|
||||
height: `${cropRect.size}px`
|
||||
}
|
||||
})
|
||||
|
||||
const initializeCrop = () => {
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics) return
|
||||
const size = Math.min(metrics.width, metrics.height)
|
||||
cropRect.size = size
|
||||
cropRect.x = (metrics.width - size) / 2
|
||||
cropRect.y = (metrics.height - size) / 2
|
||||
}
|
||||
|
||||
const triggerPictureSelect = () => {
|
||||
pictureInput.value?.click()
|
||||
}
|
||||
|
||||
const handlePictureSelected = async (event) => {
|
||||
const nextFile = event.target.files?.[0] || null
|
||||
resetEditor()
|
||||
if (!nextFile) return
|
||||
|
||||
previewObjectUrl.value = URL.createObjectURL(nextFile)
|
||||
editorImageUrl.value = previewObjectUrl.value
|
||||
const image = new Image()
|
||||
image.decoding = 'async'
|
||||
image.src = editorImageUrl.value
|
||||
try {
|
||||
await image.decode()
|
||||
editorImage.value = image
|
||||
} catch {
|
||||
errorMessage.value = 'Failed to load image'
|
||||
resetEditor()
|
||||
}
|
||||
}
|
||||
|
||||
const startMove = (event) => {
|
||||
if (!displayMetrics.value || saving.value) return
|
||||
event.preventDefault()
|
||||
dragState = {
|
||||
mode: 'move',
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
initialX: cropRect.x,
|
||||
initialY: cropRect.y,
|
||||
initialSize: cropRect.size
|
||||
}
|
||||
}
|
||||
|
||||
const startResize = (event, handle) => {
|
||||
if (!displayMetrics.value || saving.value) return
|
||||
event.preventDefault()
|
||||
dragState = {
|
||||
mode: 'resize',
|
||||
handle,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
initialX: cropRect.x,
|
||||
initialY: cropRect.y,
|
||||
initialSize: cropRect.size
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (event) => {
|
||||
if (!dragState) return
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics) return
|
||||
|
||||
const dx = event.clientX - dragState.startX
|
||||
const dy = event.clientY - dragState.startY
|
||||
|
||||
if (dragState.mode === 'move') {
|
||||
cropRect.x = Math.max(0, Math.min(metrics.width - dragState.initialSize, dragState.initialX + dx))
|
||||
cropRect.y = Math.max(0, Math.min(metrics.height - dragState.initialSize, dragState.initialY + dy))
|
||||
return
|
||||
}
|
||||
|
||||
const directionMap = {
|
||||
nw: { deltaX: -1, deltaY: -1 },
|
||||
ne: { deltaX: 1, deltaY: -1 },
|
||||
sw: { deltaX: -1, deltaY: 1 },
|
||||
se: { deltaX: 1, deltaY: 1 }
|
||||
}
|
||||
const direction = directionMap[dragState.handle]
|
||||
if (!direction) return
|
||||
|
||||
const delta = Math.max(dx * direction.deltaX, dy * direction.deltaY)
|
||||
const nextSize = Math.max(
|
||||
MIN_CROP_SIZE,
|
||||
Math.min(getResizeLimit(metrics, dragState), dragState.initialSize + delta)
|
||||
)
|
||||
|
||||
applyResize(dragState, nextSize)
|
||||
}
|
||||
|
||||
const endPointerInteraction = () => {
|
||||
dragState = null
|
||||
}
|
||||
|
||||
const getResizeLimit = (metrics, state) => {
|
||||
const { initialX, initialY, initialSize, handle } = state
|
||||
|
||||
if (handle === 'nw') return Math.min(initialX + initialSize, initialY + initialSize)
|
||||
if (handle === 'ne') return Math.min(metrics.width - initialX, initialY + initialSize)
|
||||
if (handle === 'sw') return Math.min(initialX + initialSize, metrics.height - initialY)
|
||||
return Math.min(metrics.width - initialX, metrics.height - initialY)
|
||||
}
|
||||
|
||||
const applyResize = (state, size) => {
|
||||
const { initialX, initialY, initialSize, handle } = state
|
||||
|
||||
if (handle === 'nw') {
|
||||
cropRect.x = initialX + initialSize - size
|
||||
cropRect.y = initialY + initialSize - size
|
||||
cropRect.size = size
|
||||
return
|
||||
}
|
||||
|
||||
if (handle === 'ne') {
|
||||
cropRect.x = initialX
|
||||
cropRect.y = initialY + initialSize - size
|
||||
cropRect.size = size
|
||||
return
|
||||
}
|
||||
|
||||
if (handle === 'sw') {
|
||||
cropRect.x = initialX + initialSize - size
|
||||
cropRect.y = initialY
|
||||
cropRect.size = size
|
||||
return
|
||||
}
|
||||
|
||||
cropRect.x = initialX
|
||||
cropRect.y = initialY
|
||||
cropRect.size = size
|
||||
}
|
||||
|
||||
const renderPictureBlob = async () => {
|
||||
const image = editorImage.value
|
||||
if (!image) throw new Error('No image selected')
|
||||
const metrics = displayMetrics.value
|
||||
if (!metrics || !cropRect.size) throw new Error('Crop selection unavailable')
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = AVATAR_UPLOAD_SIZE
|
||||
canvas.height = AVATAR_UPLOAD_SIZE
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Canvas unavailable')
|
||||
|
||||
const sourceScale = image.naturalWidth / metrics.width
|
||||
const sourceX = cropRect.x * sourceScale
|
||||
const sourceY = cropRect.y * sourceScale
|
||||
const sourceSize = cropRect.size * sourceScale
|
||||
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, AVATAR_UPLOAD_SIZE, AVATAR_UPLOAD_SIZE)
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to export cropped picture'))
|
||||
return
|
||||
}
|
||||
resolve(blob)
|
||||
}, 'image/webp', 0.9)
|
||||
})
|
||||
}
|
||||
|
||||
const reloadPictureFromCache = async () => {
|
||||
const response = await fetch(props.endpoint, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
cache: 'reload'
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to refresh profile picture')
|
||||
}
|
||||
|
||||
const savePicture = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
const blob = await renderPictureBlob()
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, 'profile.webp')
|
||||
await apiJson(props.endpoint, { method: 'PUT', body: formData })
|
||||
await reloadPictureFromCache()
|
||||
authStore.showMessage('Profile picture updated.', 'success', 3000)
|
||||
emit('updated')
|
||||
closeEditor()
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || 'Failed to update profile picture'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removePicture = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
await apiJson(props.endpoint, { method: 'DELETE' })
|
||||
authStore.showMessage('Profile picture removed.', 'success', 3000)
|
||||
emit('updated')
|
||||
closeEditor()
|
||||
} catch (error) {
|
||||
errorMessage.value = error.message || 'Failed to remove profile picture'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeEditor = () => {
|
||||
resetEditor()
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-picture-editor-input { display: none; }
|
||||
.profile-picture-editor-preview { position: relative; display: flex; justify-content: center; align-items: center; width: auto; max-width: min(58rem, 88vw); min-height: 0; margin: 0 auto; overflow: visible; }
|
||||
.profile-picture-editor-trigger { min-width: 0; }
|
||||
.profile-picture-editor-image { position: absolute; user-select: none; pointer-events: none; object-fit: contain; }
|
||||
.profile-picture-editor-image--overlay { filter: grayscale(0.45) saturate(0.7) brightness(0.68); }
|
||||
.profile-picture-editor-crop { position: absolute; border: 2px solid white; cursor: move; touch-action: none; }
|
||||
.profile-picture-editor-guides { position: absolute; inset: 0; pointer-events: none; }
|
||||
.profile-picture-editor-guide { position: absolute; border-color: rgba(255, 255, 255, 0.52); }
|
||||
.profile-picture-editor-guide--circle { inset: 0; border: 1.5px solid rgba(255, 255, 255, 0.62); border-radius: 999px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
|
||||
.profile-picture-editor-guide--eyes { left: 18%; right: 18%; top: 38%; border-top: 1.5px solid rgba(255, 255, 255, 0.56); }
|
||||
.profile-picture-editor-guide--cheek-left { top: 24%; bottom: 18%; left: 24%; border-left: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||
.profile-picture-editor-guide--cheek-right { top: 24%; bottom: 18%; right: 24%; border-right: 1.5px solid rgba(255, 255, 255, 0.48); }
|
||||
.profile-picture-editor-handle { position: absolute; width: 1.1rem; height: 1.1rem; border-radius: 999px; border: 2px solid white; background: var(--color-accent); padding: 0; }
|
||||
.profile-picture-editor-handle--nw { left: -0.55rem; top: -0.55rem; cursor: nwse-resize; }
|
||||
.profile-picture-editor-handle--ne { right: -0.55rem; top: -0.55rem; cursor: nesw-resize; }
|
||||
.profile-picture-editor-handle--sw { left: -0.55rem; bottom: -0.55rem; cursor: nesw-resize; }
|
||||
.profile-picture-editor-handle--se { right: -0.55rem; bottom: -0.55rem; cursor: nwse-resize; }
|
||||
:deep(.modal-panel--avatar) { width: fit-content; max-width: min(58rem, 94vw); }
|
||||
@media (max-width: 720px) {
|
||||
.profile-picture-editor-preview { max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -15,6 +15,9 @@
|
||||
v-if="authStore.userInfo?.user"
|
||||
ref="userBasicInfo"
|
||||
:name="authStore.userInfo.user.display_name"
|
||||
:avatar-url="authStore.userInfo.user.avatar_url"
|
||||
:avatar-render-version="avatarRenderVersion"
|
||||
avatar-clickable
|
||||
:email="authStore.userInfo.user.email"
|
||||
:preferred_username="authStore.userInfo.user.preferred_username"
|
||||
:telephone="authStore.userInfo.user.telephone"
|
||||
@@ -26,6 +29,7 @@
|
||||
:role-name="authStore.userInfo.role.display_name"
|
||||
update-endpoint="/auth/api/user/info"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@avatar-click="openAvatarDialog"
|
||||
@edit="openEditDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
>
|
||||
@@ -131,6 +135,15 @@
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ProfilePictureEditorModal
|
||||
v-if="showAvatarDialog && currentAvatarEndpoint"
|
||||
:endpoint="currentAvatarEndpoint"
|
||||
:picture-url="authStore.userInfo?.user?.avatar_url"
|
||||
:render-version="avatarRenderVersion"
|
||||
@close="closeAvatarDialog"
|
||||
@updated="handleProfilePictureUpdated"
|
||||
/>
|
||||
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegLink"
|
||||
endpoint="/auth/api/user/create-link"
|
||||
@@ -144,6 +157,7 @@
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue'
|
||||
import CredentialList from '@/components/CredentialList.vue'
|
||||
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
|
||||
import ThemeSelector from '@/components/ThemeSelector.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
@@ -160,11 +174,13 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
const showEditDialog = ref(false)
|
||||
const showAvatarDialog = ref(false)
|
||||
const showRegLink = ref(false)
|
||||
const editName = ref('')
|
||||
const editEmail = ref('')
|
||||
const editUsername = ref('')
|
||||
const editTelephone = ref('')
|
||||
const avatarRenderVersion = ref(0)
|
||||
const saving = ref(false)
|
||||
const editError = ref('')
|
||||
const hoveredCredentialUuid = ref(null)
|
||||
@@ -176,14 +192,15 @@ const credentialButtons = ref(null)
|
||||
const sessionList = ref(null)
|
||||
const logoutButtons = ref(null)
|
||||
const breadcrumbs = ref(null)
|
||||
const userBasicInfo = ref(null)
|
||||
const userInfoSection = ref(null)
|
||||
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
|
||||
const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
|
||||
|
||||
watch(showEditDialog, (open) => {
|
||||
if (!open) return
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const user = authStore.userInfo.user
|
||||
editName.value = user.display_name ?? ''
|
||||
editEmail.value = user.email ?? ''
|
||||
@@ -196,7 +213,28 @@ onMounted(() => {
|
||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) })
|
||||
onUnmounted(() => {
|
||||
if (updateInterval.value) clearInterval(updateInterval.value)
|
||||
})
|
||||
|
||||
const currentAvatarEndpoint = computed(() => {
|
||||
const userUuid = authStore.userInfo?.user?.uuid
|
||||
if (!userUuid) return null
|
||||
return `/auth/api/user/${userUuid}/profile.webp`
|
||||
})
|
||||
|
||||
const openAvatarDialog = () => {
|
||||
showAvatarDialog.value = true
|
||||
}
|
||||
|
||||
const closeAvatarDialog = () => {
|
||||
showAvatarDialog.value = false
|
||||
}
|
||||
|
||||
const handleProfilePictureUpdated = async () => {
|
||||
await authStore.loadUserInfo()
|
||||
avatarRenderVersion.value += 1
|
||||
}
|
||||
|
||||
const addNewCredential = async () => {
|
||||
try {
|
||||
@@ -245,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
|
||||
if (direction === 'down') {
|
||||
event.preventDefault()
|
||||
// Move to user info section - always focus edit button first
|
||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||
}
|
||||
// ArrowUp at the top does nothing
|
||||
}
|
||||
@@ -257,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
|
||||
if (!direction) return
|
||||
|
||||
event.preventDefault()
|
||||
const itemSelector = '.mini-btn, .pairing-input'
|
||||
const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
|
||||
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
|
||||
@@ -278,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
|
||||
focusPreferredButton(credentialButtons.value)
|
||||
} else if (direction === 'up' || direction === 'left') {
|
||||
// Focus user info section - always focus edit button first
|
||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' })
|
||||
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +437,7 @@ const saveProfile = async () => {
|
||||
try {
|
||||
editError.value = ''
|
||||
saving.value = true
|
||||
let changed = false
|
||||
const body = {}
|
||||
if (name !== user.display_name) body.display_name = name
|
||||
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||
@@ -406,6 +445,9 @@ const saveProfile = async () => {
|
||||
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||
if (Object.keys(body).length) {
|
||||
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
|
||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||
import { focusDialogButton } from '@/utils/keynav'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
@@ -147,7 +147,7 @@ async function fetchSettings() {
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
updateThemeFromSession(session.value?.ctx)
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
@@ -198,7 +198,7 @@ async function logoutUser() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
|
||||
session.value = null
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
@@ -220,7 +220,7 @@ async function exchangeCode(result) {
|
||||
throw new Error('Authentication response missing exchange_code')
|
||||
}
|
||||
return await fetchJson('/auth/api/set-session', {
|
||||
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
|
||||
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<template>
|
||||
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
|
||||
<div class="user-info-content">
|
||||
<div class="user-picture">
|
||||
<span>👤</span>
|
||||
</div>
|
||||
<ProfilePicture
|
||||
:src="avatarUrl"
|
||||
:render-version="avatarRenderVersion"
|
||||
:clickable="avatarClickable"
|
||||
:loading="loading"
|
||||
:title="avatarClickable ? 'Change profile picture' : ''"
|
||||
width="5.25rem"
|
||||
height="5.25rem"
|
||||
radius="var(--radius-sm)"
|
||||
fallback-size="2.8em"
|
||||
class="user-picture"
|
||||
:class="avatarClickable ? 'user-picture-btn' : ''"
|
||||
@click="emit('avatar-click')"
|
||||
/>
|
||||
<h3 class="user-name-heading">
|
||||
<span class="user-name-row">
|
||||
<span class="display-name" :title="name">{{ name }}</span>
|
||||
@@ -42,11 +53,13 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ProfilePicture from '@/components/ProfilePicture.vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
avatarUrl: { type: String, default: null },
|
||||
avatarRenderVersion: { type: [Number, String], default: 0 },
|
||||
email: { type: String, default: null },
|
||||
preferred_username: { type: String, default: null },
|
||||
telephone: { type: String, default: null },
|
||||
@@ -55,14 +68,13 @@ const props = defineProps({
|
||||
lastSeen: { type: [String, Number, Date], default: null },
|
||||
updateEndpoint: { type: String, default: null },
|
||||
canEdit: { type: Boolean, default: true },
|
||||
avatarClickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
orgDisplayName: { type: String, default: '' },
|
||||
roleName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'edit'])
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const emit = defineEmits(['saved', 'edit', 'avatar-click'])
|
||||
const userLoaded = computed(() => !!props.name)
|
||||
</script>
|
||||
|
||||
@@ -96,12 +108,12 @@ const userLoaded = computed(() => !!props.name)
|
||||
grid-template-areas:
|
||||
"picture heading fields"
|
||||
"picture org fields"
|
||||
". info info";
|
||||
"picture info info";
|
||||
gap: 0 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; }
|
||||
:deep(.user-picture) { grid-area: picture; align-self: stretch; }
|
||||
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
|
||||
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
|
||||
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { register, authenticate } from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { apiJson } from 'paskia'
|
||||
import { apiJson, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -50,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${result.session_token}`},
|
||||
timeout: paskiaSettings.auth_ms,
|
||||
})
|
||||
},
|
||||
async register() {
|
||||
@@ -87,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
|
||||
updateThemeFromSession(this.userInfo)
|
||||
console.log('User info loaded:', this.userInfo)
|
||||
} catch (error) {
|
||||
@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await apiJson('/auth/api/logout', {method: 'POST'})
|
||||
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
@@ -134,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logoutEverywhere() {
|
||||
try {
|
||||
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
||||
await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
|
||||
@@ -75,10 +75,16 @@ Discovery: `backchannel_logout_supported: true`
|
||||
- `GET /.well-known/openid-configuration` — Discovery
|
||||
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
||||
- `POST /auth/oidc/token` — Exchange/refresh
|
||||
- `GET /auth/oidc/userinfo` — User (bearer token)
|
||||
- `GET /auth/oidc/userinfo` — User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
|
||||
- `POST /auth/oidc/backchannel-logout` — Logout
|
||||
- `POST /auth/api/exchange` — Native auth code → cookie
|
||||
|
||||
## Claims
|
||||
|
||||
- `profile` scope may include `name`, `preferred_username`, and `picture`
|
||||
- `email` scope may include `email`
|
||||
- `groups` is emitted from client-scoped permissions
|
||||
|
||||
## Files
|
||||
|
||||
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
||||
|
||||
@@ -64,6 +64,30 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
|
||||
|
||||
The JSON variants set headers automatically, with body and response in JSON.
|
||||
|
||||
### Timeout Settings
|
||||
|
||||
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
|
||||
|
||||
```js
|
||||
import { settings } from 'paskia'
|
||||
|
||||
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
|
||||
settings.fetch_ms = 10000
|
||||
|
||||
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
|
||||
settings.auth_ms = 1000
|
||||
|
||||
// SessionValidator polling and idle timers
|
||||
settings.poll_ms = 60000
|
||||
settings.idle_ms = 300000
|
||||
```
|
||||
|
||||
You can still override timeout per request:
|
||||
|
||||
```js
|
||||
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
|
||||
```
|
||||
|
||||
### Authentication Overlay
|
||||
|
||||
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paskia",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Paskia authentication utilities for JavaScript",
|
||||
"author": "Leo Vasanko",
|
||||
"license": "Unlicense",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { showAuthIframe, AuthCancelledError } from './overlay'
|
||||
import settings from './settings'
|
||||
|
||||
export { AuthCancelledError }
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 1000
|
||||
|
||||
export interface ApiFetchOptions extends RequestInit {
|
||||
timeout?: number
|
||||
}
|
||||
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
|
||||
}
|
||||
|
||||
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
|
||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
||||
const { timeout = settings.fetch_ms, ...fetchOptions } = options
|
||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||
|
||||
while (true) {
|
||||
|
||||
@@ -12,6 +12,8 @@ export {
|
||||
|
||||
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
|
||||
|
||||
export { default as settings } from './settings'
|
||||
|
||||
export {
|
||||
holdGlobalBackdrop,
|
||||
releaseGlobalBackdrop,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
fetch_ms: 10000,
|
||||
auth_ms: 1000,
|
||||
poll_ms: 60000,
|
||||
idle_ms: 300000,
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { apiJson } from './fetch'
|
||||
|
||||
const POLL_INTERVAL = 60 * 1000
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
import settings from './settings'
|
||||
|
||||
export class SessionValidator {
|
||||
private userUuidGetter: () => string | undefined
|
||||
@@ -19,12 +17,12 @@ export class SessionValidator {
|
||||
resetIdleTimer(): void {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer)
|
||||
if (!this.active) this.startPolling()
|
||||
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
|
||||
this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
|
||||
}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
try {
|
||||
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
|
||||
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
|
||||
const newUuid = data.ctx?.user?.uuid
|
||||
if (newUuid !== this.userUuidGetter()) {
|
||||
window.location.reload()
|
||||
@@ -40,7 +38,7 @@ export class SessionValidator {
|
||||
startPolling(): void {
|
||||
if (this.active) return
|
||||
this.active = true
|
||||
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
|
||||
this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
|
||||
+9
-2
@@ -1,12 +1,15 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
from paskia._version import __version__
|
||||
from paskia.db.jsonl import load_readonly
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.util import startupbox
|
||||
from paskia.util.hostutil import (
|
||||
normalize_auth_host_and_origins,
|
||||
@@ -73,8 +76,12 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load stored config (read-only, no writes, no global state)
|
||||
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
||||
config = load_readonly(db_path, rp_id=args.rp_id).config
|
||||
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
|
||||
try:
|
||||
config = load_readonly(str(db_path), rp_id=args.rp_id).config
|
||||
except SystemExit as e:
|
||||
print(f"🛑 Paskia {__version__} could not load")
|
||||
sys.exit(str(e))
|
||||
|
||||
# Override stored config with CLI args, or clear with empty string
|
||||
if args.rp_name is not None:
|
||||
|
||||
@@ -14,6 +14,19 @@ from paskia.util import hostutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _configure_logger() -> None:
|
||||
if logger.handlers:
|
||||
return
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
|
||||
_configure_logger()
|
||||
|
||||
# Shared log message template for admin reset links
|
||||
ADMIN_RESET_MESSAGE = """
|
||||
👤 Admin %s
|
||||
|
||||
+21
-20
@@ -34,23 +34,21 @@ _logger = logging.getLogger(__name__)
|
||||
class ReplayResult(msgspec.Struct, frozen=False):
|
||||
"""Return value of _replay_from_data"""
|
||||
|
||||
state: dict
|
||||
state: dict = {}
|
||||
v: int = 0
|
||||
ts: datetime | None = None
|
||||
snapts: datetime | None = None
|
||||
changes: int = 0
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
class DatabaseError(ValueError):
|
||||
"""Exception raised for database loading errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
|
||||
"""Replay database state from file data, using the last snapshot if available."""
|
||||
resolved_path = str(Path(db_path).resolve())
|
||||
result = ReplayResult(state={})
|
||||
result = ReplayResult()
|
||||
|
||||
# Find and apply the last snapshot
|
||||
snap, start_offset = SnapshotState.load(data)
|
||||
@@ -61,14 +59,16 @@ def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
|
||||
|
||||
# Replay change records after the snapshot
|
||||
lines = data[start_offset:].split(b"\n")
|
||||
for line_num, raw in enumerate(lines, start=1): # 1-based line numbering
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
change = msgspec.json.decode(line, type=ChangeRecord)
|
||||
except msgspec.DecodeError as e:
|
||||
raise DatabaseError(f"{resolved_path}:{line_num}: {e}")
|
||||
raise DatabaseError(
|
||||
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
|
||||
)
|
||||
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
|
||||
result.v = change.v
|
||||
result.ts = change.ts
|
||||
@@ -88,19 +88,10 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
||||
return DB(config=Config(rp_id=rp_id))
|
||||
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
content = f.read()
|
||||
content = path.read_bytes()
|
||||
r = _replay_from_data(content, str(path.resolve()))
|
||||
data_dict = r.state
|
||||
version = r.v
|
||||
except OSError as e:
|
||||
_logger.exception("Failed to load database")
|
||||
raise SystemExit(f"{e}")
|
||||
except (ValueError, msgspec.DecodeError, DatabaseError) as e:
|
||||
raise SystemExit(f"{e}")
|
||||
except Exception as e:
|
||||
_logger.exception("Unexpected error loading database")
|
||||
raise SystemExit(f"{e}")
|
||||
|
||||
if not data_dict:
|
||||
return DB(config=Config(rp_id=rp_id))
|
||||
@@ -109,13 +100,23 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
||||
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
||||
|
||||
# Decode to msgspec struct
|
||||
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
||||
return db
|
||||
try:
|
||||
return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
||||
except msgspec.ValidationError as e:
|
||||
raise DatabaseError(f"{path.resolve()}: {e}") from None
|
||||
except OSError as e:
|
||||
_logger.exception("Failed to load database")
|
||||
raise SystemExit(f"{e}")
|
||||
except (ValueError, msgspec.DecodeError) as e:
|
||||
raise SystemExit(f"{e}")
|
||||
except Exception as e:
|
||||
_logger.exception("Unexpected error loading database")
|
||||
raise SystemExit(f"{e}")
|
||||
|
||||
|
||||
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
|
||||
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
|
||||
a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
|
||||
a: str = "" # action (e.g., "migrate", "login", "create_user")
|
||||
v: int = 0 # schema version after this change
|
||||
u: str | None = None # user UUID who performed the action (None for system)
|
||||
diff: dict
|
||||
|
||||
@@ -3,13 +3,13 @@ Database lifecycle: initialization and maintenance.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia import oidc_notify
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.paths import db_file_path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,9 +19,9 @@ async def init(rp_id: str, *args, **kwargs):
|
||||
if _ops._db._store:
|
||||
_logger.debug("Database already initialized, skipping reload")
|
||||
return
|
||||
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
|
||||
store = JsonlStore(_ops._db, db_path)
|
||||
await store.load(db_path, rp_id=rp_id)
|
||||
db_path = db_file_path(rp_id=rp_id, create_root=True)
|
||||
store = JsonlStore(_ops._db, str(db_path))
|
||||
await store.load(str(db_path), rp_id=rp_id)
|
||||
_ops._db = store.db
|
||||
_ops._db._store = store
|
||||
# Request a snapshot after successful startup
|
||||
|
||||
@@ -45,6 +45,13 @@ def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
|
||||
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
|
||||
|
||||
|
||||
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
|
||||
"""Convert config.listen from str to list[str] if needed."""
|
||||
listen = d["config"].get("listen")
|
||||
if listen and isinstance(listen, str):
|
||||
d["config"]["listen"] = [listen]
|
||||
|
||||
|
||||
migrations = sorted(
|
||||
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
||||
|
||||
@@ -437,7 +437,7 @@ def delete_credential(
|
||||
|
||||
|
||||
def update_session(
|
||||
key: bytes,
|
||||
key: str,
|
||||
host: str | None = None,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
@@ -460,9 +460,7 @@ def update_session(
|
||||
s.validated = validated
|
||||
|
||||
|
||||
def set_session_host(
|
||||
key: bytes, host: str, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Set the host for a session (first-time binding)."""
|
||||
update_session(key, host=host, ctx=ctx)
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def db_root_path(*, rp_id: str = "localhost") -> Path:
|
||||
"""Return the configured persistence root directory."""
|
||||
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
|
||||
|
||||
|
||||
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||
"""Return the JSONL database file path under the persistence root."""
|
||||
root = db_root_path(rp_id=rp_id)
|
||||
|
||||
if root.is_file():
|
||||
_migrate_legacy_db_file(root)
|
||||
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return root / "main.db"
|
||||
|
||||
|
||||
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||
"""Return the filesystem root for persisted user files."""
|
||||
root = db_root_path(rp_id=rp_id)
|
||||
|
||||
if root.is_file():
|
||||
_migrate_legacy_db_file(root)
|
||||
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return root / "users"
|
||||
|
||||
|
||||
def _migrate_legacy_db_file(legacy_path: Path) -> None:
|
||||
"""Upgrade a legacy single-file database path into a directory root."""
|
||||
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
temp_root.unlink(missing_ok=True)
|
||||
|
||||
temp_root.mkdir(parents=True)
|
||||
legacy_path.replace(temp_root / "main.db")
|
||||
temp_root.rename(legacy_path)
|
||||
@@ -17,6 +17,7 @@ from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import (
|
||||
avatar,
|
||||
permutil,
|
||||
vitedev,
|
||||
)
|
||||
@@ -26,6 +27,7 @@ from paskia.util.apistructs import (
|
||||
ApiOrg,
|
||||
ApiOrgResponse,
|
||||
ApiPermission,
|
||||
ApiUser,
|
||||
)
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -79,7 +81,11 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
|
||||
org=ApiOrg.from_db(o),
|
||||
permissions={p.uuid: p for p in o.permissions},
|
||||
roles={r.uuid: r for r in roles},
|
||||
users={u.uuid: u for r in roles for u in r.users},
|
||||
users={
|
||||
u.uuid: ApiUser.from_db(u, avatar_url=avatar.avatar_browser_url(u.uuid))
|
||||
for r in roles
|
||||
for u in r.users
|
||||
},
|
||||
)
|
||||
|
||||
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
|
||||
|
||||
@@ -9,7 +9,7 @@ from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, permutil
|
||||
from paskia.util import avatar, hostutil, permutil
|
||||
from paskia.util.apistructs import (
|
||||
ApiAaguidInfo,
|
||||
ApiCreateLinkResponse,
|
||||
@@ -165,7 +165,7 @@ async def admin_get_user_detail(
|
||||
|
||||
return MsgspecResponse(
|
||||
ApiUserDetail(
|
||||
user=ApiUser.from_db(user),
|
||||
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||
credentials={c.uuid: c for c in user.credentials},
|
||||
aaguid_info={
|
||||
k: ApiAaguidInfo(**v)
|
||||
|
||||
+87
-2
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import (
|
||||
Depends,
|
||||
@@ -20,8 +21,18 @@ from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo
|
||||
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
||||
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
|
||||
from paskia.util.apistructs import (
|
||||
ApiCheckUserResponse,
|
||||
ApiOrgContext,
|
||||
ApiRoleContext,
|
||||
ApiSessionContext,
|
||||
ApiSettings,
|
||||
ApiTokenInfo,
|
||||
ApiUserContext,
|
||||
ApiValidateResponse,
|
||||
)
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -46,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
|
||||
_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
def _set_log_extra(request: Request, *parts: str) -> None:
|
||||
values = [part for part in parts if part]
|
||||
if values:
|
||||
request.state.log_extra = " ".join(values)
|
||||
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error_handler(_request: Request, exc: ValueError):
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
@@ -100,6 +117,7 @@ async def validate_token(
|
||||
)
|
||||
session.set_session_cookie(response, auth)
|
||||
renewed = True
|
||||
_set_log_extra(request, ctx.session.key)
|
||||
return MsgspecResponse(
|
||||
ApiValidateResponse(
|
||||
valid=True,
|
||||
@@ -109,6 +127,60 @@ async def validate_token(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/check")
|
||||
async def check_user(
|
||||
request: Request,
|
||||
user_uuid: UUID = Query(..., alias="user"),
|
||||
perm: list[str] = Query([]),
|
||||
):
|
||||
"""Check permissions for a user by UUID without requiring a session.
|
||||
|
||||
Query Params:
|
||||
- user: UUID of the user to check.
|
||||
- perm: repeated permission scope the user must possess (ALL required).
|
||||
|
||||
Returns 200 with valid=True/False and the user's effective permissions,
|
||||
scoped to the requesting host (domain-restricted permissions are filtered).
|
||||
Returns 404 if the user UUID does not exist.
|
||||
|
||||
No session cookie is read or written. Caller authentication is not required.
|
||||
"""
|
||||
data = db.data()
|
||||
try:
|
||||
u = data.users[user_uuid]
|
||||
role = u.role
|
||||
org = role.org
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
host = hostutil.normalize_host(request.headers.get("host"))
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
|
||||
effective_perms = []
|
||||
for perm_uuid in role.permission_set:
|
||||
if perm_uuid not in org_perm_uuids:
|
||||
continue
|
||||
try:
|
||||
p = data.permissions[perm_uuid]
|
||||
except KeyError:
|
||||
continue
|
||||
if p.domain is not None and p.domain != host:
|
||||
continue
|
||||
effective_perms.append(p)
|
||||
|
||||
required = " ".join(perm).split()
|
||||
effective_scopes = {p.scope for p in effective_perms}
|
||||
valid = permutil.has_all_scopes(effective_scopes, required)
|
||||
|
||||
ctx = ApiSessionContext(
|
||||
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
|
||||
org=ApiOrgContext(uuid=org.uuid, display_name=org.display_name),
|
||||
role=ApiRoleContext(uuid=role.uuid, display_name=role.display_name),
|
||||
permissions=sorted(effective_scopes),
|
||||
)
|
||||
return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx))
|
||||
|
||||
|
||||
@app.get("/forward")
|
||||
async def forward_authentication(
|
||||
request: Request,
|
||||
@@ -131,6 +203,15 @@ async def forward_authentication(
|
||||
- Otherwise: JSON response with error details and an `iframe` field
|
||||
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
|
||||
"""
|
||||
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
|
||||
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
|
||||
forwarded = (
|
||||
f"{forwarded_method} {forwarded_uri}"
|
||||
if forwarded_method and forwarded_uri
|
||||
else ""
|
||||
)
|
||||
_set_log_extra(request, forwarded)
|
||||
|
||||
try:
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
@@ -138,6 +219,7 @@ async def forward_authentication(
|
||||
host=request.headers.get("host"),
|
||||
max_age=max_age,
|
||||
)
|
||||
_set_log_extra(request, forwarded, ctx.session.key)
|
||||
# Build permission scopes for Remote-Groups header
|
||||
role_permissions = (
|
||||
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
||||
@@ -212,6 +294,8 @@ async def api_user_info(
|
||||
clear_session=True,
|
||||
)
|
||||
|
||||
_set_log_extra(request, ctx.session.key)
|
||||
|
||||
return MsgspecResponse(
|
||||
await userinfo.build_user_info(
|
||||
user_uuid=ctx.user.uuid,
|
||||
@@ -287,5 +371,6 @@ async def api_set_session(
|
||||
if not ctx:
|
||||
raise HTTPException(401, f"Session not found on {host}")
|
||||
|
||||
_set_log_extra(request, hash_secret("cookie", secret))
|
||||
session.set_session_cookie(response, secret)
|
||||
return {"status": "ok", "user": str(ctx.user.uuid)}
|
||||
|
||||
+34
-16
@@ -26,8 +26,8 @@ _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||
_PATH = "\033[38;5;250m" # path (white)
|
||||
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
|
||||
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
|
||||
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
|
||||
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
|
||||
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
|
||||
_AUTHZ_USER = "\033[1;34m" # User info (light blue)
|
||||
@@ -112,7 +112,13 @@ def method_color(method: str) -> str:
|
||||
|
||||
|
||||
def format_access_log(
|
||||
client: str, status: int, method: str, host: str, path: str, duration_ms: float
|
||||
client: str,
|
||||
status: int,
|
||||
method: str,
|
||||
host: str,
|
||||
path: str,
|
||||
duration_ms: float,
|
||||
extra: str = "",
|
||||
) -> str:
|
||||
"""Format access log line with colors and aligned fields."""
|
||||
# Format components with fixed widths for alignment
|
||||
@@ -126,8 +132,11 @@ def format_access_log(
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
|
||||
# Format: "IP STATUS METHOD host path TIMING"
|
||||
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
||||
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
||||
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||
return (
|
||||
f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||
)
|
||||
|
||||
|
||||
# WebSocket connection counter (mod 100)
|
||||
@@ -152,20 +161,21 @@ def log_ws_open(ws) -> int:
|
||||
origin = ws.headers.get("origin")
|
||||
|
||||
ip = format_client_ip(client).ljust(19)
|
||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||
# ID right-aligned like status codes (3 chars), emoji formatted like method
|
||||
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
|
||||
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
|
||||
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
|
||||
|
||||
# Determine if origin should be shown (omit when same as host)
|
||||
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
||||
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||
show_origin = origin_host and origin_host != host
|
||||
|
||||
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
||||
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
||||
host_str = f"{_HOST}{host}{_RESET}"
|
||||
path_str = f"{_PATH}{path}{_RESET}"
|
||||
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||
|
||||
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
||||
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
|
||||
return ws_id
|
||||
|
||||
|
||||
@@ -191,21 +201,25 @@ WS_CLOSE_CODES = {
|
||||
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
"""Log WebSocket connection close with duration and status."""
|
||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||
# ID right-aligned like status codes (3 chars), "closed" formatted like method
|
||||
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
|
||||
# Pad within the dim color to keep full width in color (8 display chars)
|
||||
closed_str = f"{_TIMING}closed {_RESET}"
|
||||
timing = f"{duration * 1000:.0f}ms"
|
||||
|
||||
# Convert close code to status text
|
||||
if close_code is None:
|
||||
status = "closed"
|
||||
code = "----"
|
||||
status = "unknown"
|
||||
else:
|
||||
code = str(close_code)
|
||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||
|
||||
# 🔌 aligned with status, ID aligned with method
|
||||
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
||||
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
||||
# Status code and text in normal color, not dim
|
||||
status_str = f"{code} {status}"
|
||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||
|
||||
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
||||
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
|
||||
|
||||
|
||||
def log_permission_denied(
|
||||
@@ -244,7 +258,11 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
path = f"{path}?{request.url.query}"
|
||||
status = response.status_code
|
||||
|
||||
line = format_access_log(client, status, method, host, path, duration_ms)
|
||||
extra = getattr(request.state, "log_extra", "")
|
||||
|
||||
line = format_access_log(
|
||||
client, status, method, host, path, duration_ms, extra=extra
|
||||
)
|
||||
logger.info(line)
|
||||
|
||||
return response
|
||||
|
||||
@@ -126,6 +126,7 @@ async def openid_configuration(request: Request):
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"picture",
|
||||
"groups",
|
||||
"sid",
|
||||
],
|
||||
|
||||
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia.db.structs import Session
|
||||
from paskia.util import oidjwt
|
||||
from paskia.util import avatar, oidjwt
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -361,6 +361,7 @@ def _build_token_response(
|
||||
name=user.display_name,
|
||||
preferred_username=user.preferred_username,
|
||||
email=user.email,
|
||||
picture=avatar.current_avatar_url(user.uuid),
|
||||
groups=groups or None,
|
||||
auth_time=auth_time,
|
||||
)
|
||||
@@ -442,12 +443,15 @@ async def userinfo(
|
||||
|
||||
# Build userinfo response based on scope
|
||||
scope = payload.get("scope", "openid").split()
|
||||
response = {"sub": str(user.uuid)}
|
||||
response: dict[str, object] = {"sub": str(user.uuid)}
|
||||
|
||||
if "profile" in scope:
|
||||
response["name"] = user.display_name
|
||||
if user.preferred_username:
|
||||
response["preferred_username"] = user.preferred_username
|
||||
picture = avatar.current_avatar_url(user.uuid)
|
||||
if picture:
|
||||
response["picture"] = picture
|
||||
|
||||
if "email" in scope and user.email:
|
||||
response["email"] = user.email
|
||||
|
||||
@@ -312,7 +312,13 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
|
||||
# Handle authenticate request (no PoW needed - already validated during lookup)
|
||||
if msg.get("authenticate") and request is not None:
|
||||
ctx, secret = await authenticate_and_login(ws, auth)
|
||||
ctx, secret = await authenticate_and_login(
|
||||
ws,
|
||||
auth,
|
||||
session_host=request.host,
|
||||
session_ip=request.ip,
|
||||
session_user_agent=request.user_agent,
|
||||
)
|
||||
|
||||
reset_token = None
|
||||
|
||||
|
||||
+89
-2
@@ -3,11 +3,13 @@ from uuid import UUID
|
||||
from fastapi import (
|
||||
Body,
|
||||
FastAPI,
|
||||
File,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
UploadFile,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import (
|
||||
@@ -18,12 +20,48 @@ from paskia.authsession import (
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil
|
||||
from paskia.util import avatar, hostutil
|
||||
from paskia.util.apistructs import ApiCreateLinkResponse
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def _can_manage_avatar(ctx, target_user) -> bool:
|
||||
if ctx.user.uuid == target_user.uuid:
|
||||
return True
|
||||
|
||||
if any(p.scope == "auth:admin" for p in ctx.permissions):
|
||||
return True
|
||||
|
||||
return ctx.org.uuid == target_user.org.uuid and any(
|
||||
p.scope == "auth:org:admin" for p in ctx.permissions
|
||||
)
|
||||
|
||||
|
||||
def _avatar_write_ctx(request: Request, user_uuid: UUID, auth):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
|
||||
ctx = session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
|
||||
user = db.data().users.get(user_uuid)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
if not _can_manage_avatar(ctx, user):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
return ctx, user
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
@@ -103,6 +141,55 @@ async def user_update_info(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/{user_uuid}/profile.webp")
|
||||
async def serve_avatar(request: Request, user_uuid: UUID):
|
||||
"""Serve a user's current avatar with short-lived caching and ETag."""
|
||||
user = db.data().users.get(user_uuid)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
path = avatar.avatar_path(user_uuid)
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
data = avatar.read_avatar_bytes(user_uuid)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
etag = avatar.avatar_etag(data)
|
||||
if request.headers.get("if-none-match") == f'"{etag}"':
|
||||
return Response(status_code=304, headers={"ETag": f'"{etag}"'})
|
||||
|
||||
headers = {
|
||||
"ETag": f'"{etag}"',
|
||||
"Cache-Control": "public, max-age=300",
|
||||
}
|
||||
|
||||
return FileResponse(path, media_type="image/webp", headers=headers)
|
||||
|
||||
|
||||
@app.put("/{user_uuid}/profile.webp")
|
||||
async def upload_avatar(
|
||||
request: Request,
|
||||
user_uuid: UUID,
|
||||
file: UploadFile = File(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Upload a user's browser-prepared WebP avatar on the same URL it is served from."""
|
||||
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
|
||||
data = await avatar.read_upload(file)
|
||||
avatar.store_avatar(user_uuid, data)
|
||||
return {"status": "ok", "avatar_url": avatar.avatar_browser_url(user_uuid)}
|
||||
|
||||
|
||||
@app.delete("/{user_uuid}/profile.webp")
|
||||
async def delete_avatar(request: Request, user_uuid: UUID, auth=AUTH_COOKIE):
|
||||
"""Delete a user's avatar image on the same URL it is served from."""
|
||||
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
|
||||
avatar.remove_avatar_file(user_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/theme")
|
||||
async def user_update_theme(
|
||||
request: Request,
|
||||
|
||||
@@ -69,11 +69,22 @@ async def authenticate_chat(
|
||||
async def authenticate_and_login(
|
||||
ws: WebSocket,
|
||||
auth: str | None = None,
|
||||
*,
|
||||
session_host: str | None = None,
|
||||
session_ip: str | None = None,
|
||||
session_user_agent: str | None = None,
|
||||
) -> tuple[SessionContext, str]:
|
||||
"""Run WebAuthn authentication flow, create session, and return the session context.
|
||||
|
||||
If auth is provided, restrict authentication to credentials of that session's user.
|
||||
|
||||
Args:
|
||||
ws: The WebSocket connection (used for WebAuthn and origin validation)
|
||||
auth: Existing session cookie for re-auth credential restriction
|
||||
session_host: Override host for the new session (defaults to ws origin)
|
||||
session_ip: Override IP for the new session (defaults to ws client IP)
|
||||
session_user_agent: Override user-agent for the new session (defaults to ws headers)
|
||||
|
||||
Returns:
|
||||
Tuple of (SessionContext for the authenticated session, session secret)
|
||||
"""
|
||||
@@ -97,18 +108,31 @@ async def authenticate_and_login(
|
||||
|
||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||
|
||||
# Use overrides if provided, otherwise use websocket metadata
|
||||
login_host = (
|
||||
hostutil.normalize_host(session_host)
|
||||
if session_host is not None
|
||||
else normalized_host
|
||||
)
|
||||
if not login_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
login_ip = session_ip if session_ip is not None else metadata["ip"]
|
||||
login_user_agent = (
|
||||
session_user_agent if session_user_agent is not None else metadata["user_agent"]
|
||||
)
|
||||
|
||||
# Create session and update user/credential
|
||||
secret = db.login(
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
ip=metadata["ip"],
|
||||
user_agent=metadata["user_agent"],
|
||||
host=login_host,
|
||||
ip=login_ip,
|
||||
user_agent=login_user_agent,
|
||||
)
|
||||
|
||||
# Fetch and return the full session context
|
||||
ctx = session_ctx(secret, host)
|
||||
# Fetch and return the full session context (using the same host the session was created with)
|
||||
ctx = session_ctx(secret, login_host)
|
||||
if not ctx:
|
||||
raise ValueError("Failed to create session context")
|
||||
return ctx, secret
|
||||
|
||||
@@ -24,10 +24,11 @@ class ApiUser(User, kw_only=True):
|
||||
"""User with uuid serialized."""
|
||||
|
||||
uuid: UUID
|
||||
avatar_url: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, u: User) -> ApiUser:
|
||||
return cls(uuid=u.uuid, **msgspec.structs.asdict(u))
|
||||
def from_db(cls, u: User, *, avatar_url: str | None = None) -> ApiUser:
|
||||
return cls(uuid=u.uuid, avatar_url=avatar_url, **msgspec.structs.asdict(u))
|
||||
|
||||
|
||||
class ApiOrg(Org, kw_only=True):
|
||||
@@ -139,7 +140,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
|
||||
user: ApiUser
|
||||
credentials: dict[UUID, Credential]
|
||||
aaguid_info: dict[str, ApiAaguidInfo]
|
||||
sessions: dict[bytes, ApiUserSession]
|
||||
sessions: dict[str, ApiUserSession]
|
||||
permissions: dict[UUID, ApiPermission] = {}
|
||||
org: ApiOrg | None = None
|
||||
role: ApiRole | None = None
|
||||
@@ -156,7 +157,7 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
||||
org: ApiOrg
|
||||
permissions: dict[UUID, Permission]
|
||||
roles: dict[UUID, Role]
|
||||
users: dict[UUID, User]
|
||||
users: dict[UUID, ApiUser]
|
||||
|
||||
|
||||
class ApiSettings(msgspec.Struct):
|
||||
@@ -233,6 +234,13 @@ class ApiValidateResponse(msgspec.Struct):
|
||||
ctx: ApiSessionContext
|
||||
|
||||
|
||||
class ApiCheckUserResponse(msgspec.Struct):
|
||||
"""Response struct for check-user endpoint."""
|
||||
|
||||
valid: bool
|
||||
ctx: ApiSessionContext
|
||||
|
||||
|
||||
class ApiAdminInfo(msgspec.Struct, kw_only=True):
|
||||
"""Combined admin info response."""
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Avatar storage and URL helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from paskia.db.paths import users_root_path
|
||||
from paskia.util import hostutil
|
||||
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def media_root() -> Path:
|
||||
"""Return the filesystem root for auxiliary media files."""
|
||||
return users_root_path(create_root=True)
|
||||
|
||||
|
||||
def avatars_root() -> Path:
|
||||
"""Return the filesystem root for stored avatar images."""
|
||||
return media_root()
|
||||
|
||||
|
||||
def avatar_path(user_uuid: UUID) -> Path:
|
||||
"""Return the avatar file path for a user."""
|
||||
return avatars_root() / str(user_uuid) / "profile.webp"
|
||||
|
||||
|
||||
def avatar_public_path(user_uuid: UUID) -> str:
|
||||
"""Return the public relative path for a user's avatar."""
|
||||
return f"/auth/api/user/{user_uuid}/profile.webp"
|
||||
|
||||
|
||||
def avatar_browser_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the browser-facing avatar URL."""
|
||||
if not avatar_path(user_uuid).is_file():
|
||||
return None
|
||||
return avatar_public_path(user_uuid)
|
||||
|
||||
|
||||
def avatar_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the absolute public avatar URL for a user, or None."""
|
||||
if not avatar_path(user_uuid).is_file():
|
||||
return None
|
||||
return f"{hostutil.auth_site_url()}api/user/{user_uuid}/profile.webp"
|
||||
|
||||
|
||||
def current_avatar_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the current absolute avatar URL for a user UUID."""
|
||||
return avatar_url(user_uuid)
|
||||
|
||||
|
||||
def remove_avatar_file(user_uuid: UUID) -> None:
|
||||
"""Delete a stored avatar file if it exists."""
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
avatar_path(user_uuid).unlink()
|
||||
|
||||
|
||||
def read_avatar_bytes(user_uuid: UUID) -> bytes | None:
|
||||
"""Read the stored avatar file for a user, if present."""
|
||||
path = avatar_path(user_uuid)
|
||||
if not path.is_file():
|
||||
return None
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def _is_webp(data: bytes) -> bool:
|
||||
"""Return True when bytes look like a RIFF WebP file."""
|
||||
return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP"
|
||||
|
||||
|
||||
async def read_upload(upload: UploadFile) -> bytes:
|
||||
"""Read an uploaded avatar and require it to already be WebP."""
|
||||
data = await upload.read(MAX_UPLOAD_BYTES + 1)
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="No avatar file uploaded")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Avatar upload too large")
|
||||
|
||||
if not _is_webp(data):
|
||||
raise HTTPException(status_code=400, detail="Avatar upload must be WebP")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def store_avatar(user_uuid: UUID, data: bytes) -> None:
|
||||
"""Store avatar bytes."""
|
||||
path = avatar_path(user_uuid)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
||||
|
||||
def avatar_etag(data: bytes) -> str:
|
||||
"""Return a stable ETag value for avatar bytes."""
|
||||
return hashlib.sha256(data).hexdigest()[:16]
|
||||
+25
-14
@@ -78,6 +78,7 @@ def create_id_token(
|
||||
name: str | None = None,
|
||||
preferred_username: str | None = None,
|
||||
email: str | None = None,
|
||||
picture: str | None = None,
|
||||
groups: list[str] | None = None,
|
||||
auth_time: datetime | None = None,
|
||||
expires_in: int = 3600,
|
||||
@@ -93,6 +94,7 @@ def create_id_token(
|
||||
name: User's display name
|
||||
preferred_username: User's preferred username
|
||||
email: User's email address
|
||||
picture: User avatar URL
|
||||
groups: List of permission scopes (groups claim)
|
||||
auth_time: When the user authenticated (last credential use time)
|
||||
expires_in: Token lifetime in seconds
|
||||
@@ -101,8 +103,9 @@ def create_id_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"sub": str(subject),
|
||||
"aud": audience,
|
||||
@@ -119,6 +122,8 @@ def create_id_token(
|
||||
payload["preferred_username"] = preferred_username
|
||||
if email:
|
||||
payload["email"] = email
|
||||
if picture:
|
||||
payload["picture"] = picture
|
||||
if groups:
|
||||
payload["groups"] = groups
|
||||
if auth_time:
|
||||
@@ -147,8 +152,9 @@ def create_access_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"sub": str(subject),
|
||||
"aud": audience,
|
||||
@@ -173,20 +179,24 @@ def decode_access_token(
|
||||
Decoded payload or None if invalid
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _public_key is not None
|
||||
try:
|
||||
# PyJWT requires audience parameter when token has aud claim.
|
||||
# When audience is None, we skip PyJWT's audience validation and validate manually.
|
||||
options = {}
|
||||
decode_kwargs = {
|
||||
"algorithms": ["EdDSA"],
|
||||
"issuer": issuer,
|
||||
}
|
||||
if audience is not None:
|
||||
decode_kwargs["audience"] = audience
|
||||
else:
|
||||
options["verify_aud"] = False
|
||||
return jwt.decode(
|
||||
token,
|
||||
_public_key,
|
||||
algorithms=["EdDSA"],
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
)
|
||||
|
||||
return jwt.decode(token, _public_key, options=options, **decode_kwargs)
|
||||
return jwt.decode(
|
||||
token,
|
||||
_public_key,
|
||||
algorithms=["EdDSA"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
@@ -212,8 +222,9 @@ def create_logout_token(
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"iat": int(now.timestamp()),
|
||||
|
||||
@@ -6,7 +6,7 @@ from fnmatch import fnmatchcase
|
||||
from paskia.authsession import session_ctx
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
__all__ = ["has_any", "has_all", "session_context"]
|
||||
__all__ = ["has_any", "has_all", "has_all_scopes", "session_context"]
|
||||
|
||||
|
||||
def _match(perms: set[str], patterns: Sequence[str]):
|
||||
@@ -36,6 +36,11 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
|
||||
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
||||
|
||||
|
||||
def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool:
|
||||
"""Check that a pre-computed scope set satisfies all required patterns."""
|
||||
return all(_match(scopes, patterns)) if patterns else True
|
||||
|
||||
|
||||
async def session_context(auth: str | None, host: str | None = None):
|
||||
if not auth:
|
||||
return None
|
||||
|
||||
@@ -19,8 +19,8 @@ BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||
|
||||
# ANSI color codes
|
||||
RESET = "\033[0m"
|
||||
YELLOW = "\033[33m" # Dark yellow
|
||||
BRIGHT_YELLOW = "\033[93m" # Bright yellow
|
||||
YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
|
||||
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
|
||||
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ def bottom() -> str:
|
||||
def print_startup_config(runtime: RuntimeConfig) -> None:
|
||||
"""Print server configuration on startup."""
|
||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||
y = YELLOW # Dark yellow for main body
|
||||
b = BRIGHT_YELLOW # Bright yellow for highlights/edges
|
||||
y = YELLOW # Bright golden yellow for main body
|
||||
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
|
||||
w = BRIGHT_WHITE # Bold white for URL
|
||||
r = RESET
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from paskia import aaguid, db
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util import hostutil
|
||||
from paskia.util import avatar, hostutil
|
||||
from paskia.util.apistructs import (
|
||||
ApiAaguidInfo,
|
||||
ApiOrg,
|
||||
@@ -56,7 +56,7 @@ async def build_user_info(
|
||||
}
|
||||
|
||||
return ApiUserDetail(
|
||||
user=ApiUser.from_db(user),
|
||||
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
|
||||
credentials={c.uuid: c for c in user.credentials},
|
||||
aaguid_info={
|
||||
k: ApiAaguidInfo(**v)
|
||||
|
||||
@@ -283,3 +283,27 @@ def create_test_session(
|
||||
with ops_db._db.transaction("create_test_session"):
|
||||
session.store(now)
|
||||
return session.key, token
|
||||
|
||||
|
||||
def create_test_image_bytes(
|
||||
*,
|
||||
image_format: str = "WEBP",
|
||||
) -> bytes:
|
||||
"""Return deterministic test upload bytes without image-library dependencies."""
|
||||
fixtures = {
|
||||
"WEBP": (
|
||||
b"RIFF\x1a\x00\x00\x00WEBPVP8 "
|
||||
b"\x0e\x00\x00\x000\x01\x00\x9d\x01*\x01\x00\x01\x00\x01\x00"
|
||||
),
|
||||
"PNG": (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
b"\x00\x00\x00\rIHDR"
|
||||
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
|
||||
b"\x90wS\xde"
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
return fixtures[image_format.upper()]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unsupported test image format: {image_format}") from exc
|
||||
|
||||
+62
-1
@@ -14,6 +14,7 @@ These tests cover:
|
||||
import os
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
@@ -37,7 +38,7 @@ from paskia.db import (
|
||||
)
|
||||
from paskia.db.operations import DB
|
||||
from paskia.util.crypto import hash_secret
|
||||
from tests.conftest import auth_headers, create_test_session
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
|
||||
# -------------------- Additional Fixtures --------------------
|
||||
|
||||
@@ -238,6 +239,38 @@ class TestAdminOrganizations:
|
||||
assert "roles" in org_data
|
||||
assert "users" in org_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orgs_includes_user_avatar_urls(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_org,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Admin org payload should include canonical avatar URLs for listed users."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
"/auth/api/admin/info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
listed_user = data["orgs"][str(test_org.uuid)]["users"][str(test_user.uuid)]
|
||||
parts = urlsplit(listed_user["avatar_url"])
|
||||
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||
assert parts.query == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orgs_with_org_admin(
|
||||
self,
|
||||
@@ -902,6 +935,34 @@ class TestAdminUsersInOrg:
|
||||
data = response.json()
|
||||
assert "display_name too long" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_can_upload_user_avatar(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user: User,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Admin should be able to upload avatar for a managed user."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
detail = await client.get(
|
||||
f"/auth/api/admin/users/{test_user.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
avatar_url = detail.json()["user"]["avatar_url"]
|
||||
parts = urlsplit(avatar_url)
|
||||
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_in_org(
|
||||
self,
|
||||
|
||||
+128
-1
@@ -12,6 +12,7 @@ These tests cover:
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -19,8 +20,10 @@ import pytest
|
||||
from paskia import authcode
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import delete_session
|
||||
from paskia.db.structs import Client
|
||||
from paskia.util import oidjwt
|
||||
from paskia.util.passphrase import generate
|
||||
from tests.conftest import auth_headers, create_test_session
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
|
||||
|
||||
class TestSettingsEndpoint:
|
||||
@@ -46,6 +49,15 @@ class TestSettingsEndpoint:
|
||||
data = response.json()
|
||||
assert "ui_base_path" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openid_configuration_includes_picture_claim(
|
||||
self, client: httpx.AsyncClient
|
||||
):
|
||||
"""Discovery document should advertise picture claim support."""
|
||||
response = await client.get("/.well-known/openid-configuration")
|
||||
assert response.status_code == 200
|
||||
assert "picture" in response.json()["claims_supported"]
|
||||
|
||||
|
||||
class TestValidateEndpoint:
|
||||
"""Tests for POST /auth/api/validate"""
|
||||
@@ -294,6 +306,121 @@ class TestUserInfoEndpoint:
|
||||
data = response.json()
|
||||
assert "permissions" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_includes_avatar_url(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""User info should include the canonical avatar URL when present."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
"/auth/api/user-info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
avatar_url = data["user"]["avatar_url"]
|
||||
parts = urlsplit(avatar_url)
|
||||
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
|
||||
assert parts.query == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avatar_route_returns_304_for_matching_etag(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Avatar route should honor If-None-Match for unchanged avatars."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
parts = urlsplit(upload.json()["avatar_url"])
|
||||
|
||||
first = await client.get(parts.path, headers={"Host": "localhost:4401"})
|
||||
assert first.status_code == 200
|
||||
|
||||
response = await client.get(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
headers={
|
||||
"Host": "localhost:4401",
|
||||
"If-None-Match": first.headers["etag"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 304
|
||||
assert response.headers["etag"] == first.headers["etag"]
|
||||
|
||||
|
||||
class TestOidcUserInfoEndpoint:
|
||||
"""Tests for OIDC userinfo metadata relevant to avatars."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_userinfo_includes_picture_claim(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
test_db,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""OIDC userinfo should expose picture when profile scope is granted."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
avatar_url = upload.json()["avatar_url"]
|
||||
|
||||
oidc_client, _secret = Client.create(
|
||||
name="Test Client",
|
||||
redirect_uris=["https://client.example/callback"],
|
||||
client_secret="topsecret",
|
||||
)
|
||||
with test_db.transaction("create_test_oidc_client"):
|
||||
test_db.oidc.clients[oidc_client.uuid] = oidc_client
|
||||
|
||||
access_token = oidjwt.create_access_token(
|
||||
issuer="http://localhost:4401",
|
||||
subject=test_user.uuid,
|
||||
audience=str(oidc_client.uuid),
|
||||
scope="openid profile",
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
"/auth/oidc/userinfo",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Host": "localhost:4401",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert urlsplit(data["picture"]).path == urlsplit(avatar_url).path
|
||||
assert data["picture"].startswith("http")
|
||||
|
||||
|
||||
class TestSetSessionEndpoint:
|
||||
"""Tests for POST /auth/api/set-session"""
|
||||
|
||||
+161
-1
@@ -3,16 +3,20 @@ Tests for the user API endpoints (/auth/api/user/).
|
||||
|
||||
These tests cover user self-service operations:
|
||||
- Display name update
|
||||
- Avatar upload/delete
|
||||
- Logout all sessions
|
||||
- Session management (delete specific session)
|
||||
- Credential management (delete credential)
|
||||
- Device addition link creation
|
||||
"""
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.conftest import auth_headers
|
||||
from paskia.db.paths import db_file_path, users_root_path
|
||||
from tests.conftest import auth_headers, create_test_image_bytes
|
||||
|
||||
|
||||
class TestUserDisplayName:
|
||||
@@ -67,6 +71,162 @@ class TestUserDisplayName:
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestUserAvatar:
|
||||
"""Tests for PUT/DELETE /auth/api/user/{user_uuid}/profile.webp"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_avatar_requires_auth(self, client: httpx.AsyncClient):
|
||||
"""Uploading avatar without auth should return 401."""
|
||||
response = await client.put(
|
||||
"/auth/api/user/00000000-0000-0000-0000-000000000000/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
)
|
||||
assert response.status_code in (401, 404)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_avatar_success(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Uploading a WebP avatar should store and expose the canonical URL."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload_bytes = create_test_image_bytes()
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", upload_bytes, "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
avatar_url = data["avatar_url"]
|
||||
parts = urlsplit(avatar_url)
|
||||
assert parts.query == ""
|
||||
|
||||
avatar_response = await client.get(
|
||||
parts.path,
|
||||
headers={"Host": "localhost:4401"},
|
||||
)
|
||||
assert avatar_response.status_code == 200
|
||||
assert avatar_response.headers["cache-control"] == "public, max-age=300"
|
||||
assert avatar_response.headers["content-type"] == "image/webp"
|
||||
assert "etag" in avatar_response.headers
|
||||
assert avatar_response.content == upload_bytes
|
||||
|
||||
not_modified = await client.get(
|
||||
parts.path,
|
||||
headers={
|
||||
"Host": "localhost:4401",
|
||||
"If-None-Match": avatar_response.headers["etag"],
|
||||
},
|
||||
)
|
||||
assert not_modified.status_code == 304
|
||||
assert not_modified.headers["etag"] == avatar_response.headers["etag"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_avatar_rejects_non_webp(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Avatar uploads must already be browser-prepared WebP."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={
|
||||
"file": (
|
||||
"avatar.png",
|
||||
create_test_image_bytes(image_format="PNG"),
|
||||
"image/png",
|
||||
)
|
||||
},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Avatar upload must be WebP"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_avatar_success(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Deleting avatar should clear the user avatar URL."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
info = await client.get(
|
||||
"/auth/api/user-info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert info.status_code == 200
|
||||
assert info.json()["user"].get("avatar_url") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_cannot_upload_another_users_avatar(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
regular_session_token: str,
|
||||
session_token: str,
|
||||
test_user,
|
||||
):
|
||||
"""A non-admin user should not be able to upload another user's avatar."""
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_paskia_db_legacy_file_is_migrated_to_root_dir(tmp_path, monkeypatch):
|
||||
legacy_path = tmp_path / "legacy.paskiadb"
|
||||
legacy_bytes = b'{"v":0}\n'
|
||||
legacy_path.write_bytes(legacy_bytes)
|
||||
|
||||
monkeypatch.setenv("PASKIA_DB", str(legacy_path))
|
||||
|
||||
db_path = db_file_path(create_root=True)
|
||||
|
||||
assert legacy_path.is_dir()
|
||||
assert db_path == legacy_path / "main.db"
|
||||
assert db_path.read_bytes() == legacy_bytes
|
||||
|
||||
|
||||
def test_paskia_db_root_uses_users_directory(tmp_path, monkeypatch):
|
||||
root_path = tmp_path / "instance-root"
|
||||
monkeypatch.setenv("PASKIA_DB", str(root_path))
|
||||
|
||||
users_path = users_root_path(create_root=True)
|
||||
|
||||
assert users_path == root_path / "users"
|
||||
assert users_path.parent == root_path
|
||||
|
||||
|
||||
class TestUserLogoutAll:
|
||||
"""Tests for POST /auth/api/user/logout-all"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user