Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ee8ddf1d1 | ||
|
|
d58b3742b1 | ||
|
|
5879be39a5 | ||
|
|
5b3406025c | ||
|
|
dda57ac27d | ||
|
|
5e12dcba76 | ||
|
|
be7a9e7f00 | ||
|
|
3d2151fed7 | ||
|
|
58b56a09a4 | ||
|
|
731b36b456 | ||
|
|
8f9cd1124c | ||
|
|
7e49ef296a | ||
|
|
af35ff3d4c | ||
|
|
dac1415a86 | ||
|
|
433844cf08 | ||
|
|
c9ea1c8948 | ||
|
|
58f46c6abf |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+14
-48
@@ -13,7 +13,8 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, getAuthIframeUrl } from '@/utils/api'
|
||||
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
|
||||
import { getAuthIframeUrl } from '@/utils/api'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import HostProfileView from '@/components/HostProfileView.vue'
|
||||
@@ -46,19 +47,23 @@ const isHostMode = computed(() => {
|
||||
const configuredHost = normalizeHost(authHost)
|
||||
return currentHost !== configuredHost
|
||||
})
|
||||
let validationTimer = null
|
||||
let authIframe = null
|
||||
const userUuid = computed(() => store.userInfo?.ctx.user.uuid)
|
||||
|
||||
function terminateSession() {
|
||||
store.userInfo = null
|
||||
viewState.value = 'terminal'
|
||||
}
|
||||
|
||||
const userUuidGetter = () => store.userInfo?.ctx.user.uuid
|
||||
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
|
||||
|
||||
onMounted(() => sessionValidator.start())
|
||||
onUnmounted(() => sessionValidator.stop())
|
||||
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
viewState.value = 'profile'
|
||||
startSessionValidation()
|
||||
return true
|
||||
} catch {
|
||||
store.userInfo = null
|
||||
@@ -67,27 +72,11 @@ async function loadUserInfo() {
|
||||
}
|
||||
|
||||
async function showAuthIframe() {
|
||||
// Remove existing iframe if any
|
||||
hideAuthIframe()
|
||||
|
||||
// Create new iframe for authentication using src URL
|
||||
const url = await getAuthIframeUrl('login')
|
||||
authIframe = document.createElement('iframe')
|
||||
authIframe.id = 'auth-iframe'
|
||||
authIframe.title = 'Authentication'
|
||||
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
|
||||
authIframe.src = url
|
||||
document.body.appendChild(authIframe)
|
||||
createAuthIframe(url)
|
||||
loadingMessage.value = 'Authentication required...'
|
||||
}
|
||||
|
||||
function hideAuthIframe() {
|
||||
if (authIframe) {
|
||||
authIframe.remove()
|
||||
authIframe = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthMessage(event) {
|
||||
const data = event.data
|
||||
if (!data?.type) return
|
||||
@@ -95,7 +84,7 @@ function handleAuthMessage(event) {
|
||||
switch (data.type) {
|
||||
case 'auth-success':
|
||||
// Authentication successful - reload user info
|
||||
hideAuthIframe()
|
||||
removeAuthIframe()
|
||||
viewState.value = 'loading'
|
||||
loadingMessage.value = 'Loading user profile...'
|
||||
loadUserInfo()
|
||||
@@ -117,39 +106,17 @@ function handleAuthMessage(event) {
|
||||
|
||||
case 'auth-back':
|
||||
// User clicked Back - show terminal state
|
||||
hideAuthIframe()
|
||||
removeAuthIframe()
|
||||
terminateSession()
|
||||
break
|
||||
|
||||
case 'auth-close-request':
|
||||
// Legacy support - treat as back
|
||||
hideAuthIframe()
|
||||
removeAuthIframe()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
} catch {
|
||||
stopSessionValidation()
|
||||
terminateSession()
|
||||
}
|
||||
}
|
||||
|
||||
function startSessionValidation() {
|
||||
// Validate session every 2 minutes
|
||||
stopSessionValidation()
|
||||
validationTimer = setInterval(validateSession, 2 * 60 * 1000)
|
||||
}
|
||||
|
||||
function stopSessionValidation() {
|
||||
if (validationTimer) {
|
||||
clearInterval(validationTimer)
|
||||
validationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Listen for postMessage from auth iframe
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
@@ -178,8 +145,7 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', handleAuthMessage)
|
||||
stopSessionValidation()
|
||||
hideAuthIframe()
|
||||
removeAuthIframe()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { apiJson, SessionValidator } from 'paskia'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
|
||||
@@ -157,6 +157,21 @@ function clearSensitiveState() {
|
||||
authenticated.value = false
|
||||
}
|
||||
|
||||
function onSessionLost(e) {
|
||||
clearSensitiveState()
|
||||
if (e.name === 'AuthCancelledError') {
|
||||
showBackMessage.value = true
|
||||
} else {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
const userUuidGetter = () => info.value?.ctx.user.uuid
|
||||
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
||||
|
||||
onMounted(() => sessionValidator.start())
|
||||
onUnmounted(() => sessionValidator.stop())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadingMessage.value = 'Loading...'
|
||||
@@ -177,12 +192,7 @@ async function load() {
|
||||
}
|
||||
} else parseHash()
|
||||
} catch (e) {
|
||||
clearSensitiveState()
|
||||
if (e.name === 'AuthCancelledError') {
|
||||
showBackMessage.value = true
|
||||
} else {
|
||||
error.value = e.message
|
||||
}
|
||||
onSessionLost(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -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 '@/utils/api'
|
||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
||||
|
||||
const status = reactive({
|
||||
show: false,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"paskia": "file:../paskia-js",
|
||||
"pinia": "^3.0.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"sirv": "^3.0.2",
|
||||
|
||||
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { apiJson } from 'paskia'
|
||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -731,42 +731,6 @@ th {
|
||||
}
|
||||
}
|
||||
|
||||
/* Global backdrop controlled by api.js ref-counting */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1099;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(0) brightness(1);
|
||||
-webkit-backdrop-filter: blur(0) brightness(1);
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
body.has-backdrop::before {
|
||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
||||
backdrop-filter: blur(.2rem) brightness(0.5);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
body.has-backdrop {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#auth-iframe {
|
||||
border: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
color-scheme: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.slot-machine {
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
||||
|
||||
@@ -127,7 +127,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { apiJson } from 'paskia'
|
||||
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -35,9 +35,10 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { apiJson } from 'paskia'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: String, required: true },
|
||||
@@ -46,6 +47,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close', 'copied'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const dialog = ref(null)
|
||||
const linkUrl = ref(null)
|
||||
const expiresAt = ref(null)
|
||||
@@ -73,7 +75,8 @@ async function generateLink() {
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '@/utils/api'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||
import { focusDialogButton } from '@/utils/keynav'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { register, authenticate } from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { apiJson } from 'paskia'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
|
||||
@@ -1,77 +1,3 @@
|
||||
/**
|
||||
* API fetch wrapper that handles authentication errors with iframe-based re-authentication.
|
||||
*
|
||||
* When a 401 or 403 response is received with an `auth` object containing `iframe` URL,
|
||||
* this wrapper shows an authentication iframe and retries the original request after
|
||||
* successful authentication.
|
||||
*/
|
||||
|
||||
/** Default timeout for API requests in milliseconds */
|
||||
const DEFAULT_TIMEOUT_MS = 1000
|
||||
|
||||
/**
|
||||
* Custom error class for API errors with full response context.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(url, response, data) {
|
||||
super(data?.detail || `Request failed: ${response.status}`)
|
||||
this.name = 'ApiError'
|
||||
this.url = url
|
||||
this.status = response.status
|
||||
this.statusText = response.statusText
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for network/timeout errors.
|
||||
*/
|
||||
export class NetworkError extends Error {
|
||||
constructor(message, originalError = null) {
|
||||
super(message)
|
||||
this.name = 'NetworkError'
|
||||
this.originalError = originalError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when user cancels authentication.
|
||||
*/
|
||||
export class AuthCancelledError extends Error {
|
||||
constructor() {
|
||||
super('Authentication cancelled')
|
||||
this.name = 'AuthCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
let authIframe = null
|
||||
let authPromise = null
|
||||
let authResolve = null
|
||||
let authReject = null
|
||||
|
||||
// Global backdrop ref-count (works independently of Pinia store)
|
||||
let backdropHolders = 0
|
||||
|
||||
/**
|
||||
* Hold global backdrop (increment ref-count).
|
||||
* Multiple callers can hold the backdrop; it only hides when all release.
|
||||
*/
|
||||
export function holdGlobalBackdrop() {
|
||||
backdropHolders++
|
||||
document.body.classList.add('has-backdrop')
|
||||
}
|
||||
|
||||
/**
|
||||
* Release global backdrop (decrement ref-count).
|
||||
* Backdrop hides only when ref-count reaches zero.
|
||||
*/
|
||||
export function releaseGlobalBackdrop() {
|
||||
backdropHolders = Math.max(0, backdropHolders - 1)
|
||||
if (backdropHolders === 0) {
|
||||
document.body.classList.remove('has-backdrop')
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for auth iframe URL by mode
|
||||
const authIframeUrlCache = {}
|
||||
|
||||
@@ -104,302 +30,3 @@ export async function getAuthIframeUrl(mode = 'login') {
|
||||
}
|
||||
throw new Error('Unable to fetch auth iframe URL')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an auth iframe is already open (from any source).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAuthIframeOpen() {
|
||||
return !!document.getElementById('auth-iframe')
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the authentication iframe and return a promise that resolves on success.
|
||||
* If an auth iframe is already open (from any source), hooks into its completion.
|
||||
* Uses global backdrop system to avoid flicker between auth and caller's UI.
|
||||
* @param {string} iframeUrl - The URL for the iframe src
|
||||
* @returns {Promise<void>}
|
||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||
*/
|
||||
export function showAuthIframe(iframeUrl) {
|
||||
// If we already have a promise (from us), return it
|
||||
if (authPromise) return authPromise
|
||||
|
||||
// If there's already an iframe in the DOM (from App.vue or elsewhere),
|
||||
// create a promise that hooks into the message handler
|
||||
if (document.getElementById('auth-iframe')) {
|
||||
authPromise = new Promise((resolve, reject) => {
|
||||
authResolve = resolve
|
||||
authReject = reject
|
||||
})
|
||||
return authPromise
|
||||
}
|
||||
|
||||
authPromise = new Promise((resolve, reject) => {
|
||||
authResolve = resolve
|
||||
authReject = reject
|
||||
})
|
||||
|
||||
// Remove existing iframe if any
|
||||
hideAuthIframe()
|
||||
|
||||
// Hold global backdrop for auth iframe
|
||||
holdGlobalBackdrop()
|
||||
|
||||
// Create new iframe for authentication using src URL
|
||||
authIframe = document.createElement('iframe')
|
||||
authIframe.id = 'auth-iframe'
|
||||
authIframe.title = 'Authentication'
|
||||
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
|
||||
authIframe.src = iframeUrl
|
||||
document.body.appendChild(authIframe)
|
||||
|
||||
return authPromise
|
||||
}
|
||||
|
||||
function hideAuthIframe() {
|
||||
if (authIframe) {
|
||||
authIframe.remove()
|
||||
authIframe = null
|
||||
releaseGlobalBackdrop()
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthMessage(event) {
|
||||
const data = event.data
|
||||
if (!data?.type) return
|
||||
|
||||
switch (data.type) {
|
||||
case 'auth-success':
|
||||
hideAuthIframe()
|
||||
if (authResolve) {
|
||||
authResolve()
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
}
|
||||
break
|
||||
|
||||
case 'auth-back':
|
||||
case 'auth-close-request':
|
||||
hideAuthIframe()
|
||||
if (authReject) {
|
||||
authReject(new AuthCancelledError())
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
}
|
||||
break
|
||||
|
||||
case 'auth-error':
|
||||
// Keep iframe open for retry, but if cancelled, treat as back
|
||||
if (data.cancelled && authReject) {
|
||||
hideAuthIframe()
|
||||
authReject(new AuthCancelledError())
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Install global message listener
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper that handles auth errors with iframe-based re-authentication.
|
||||
* Loops until successful or user cancels authentication.
|
||||
*
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @param {number} [options.timeout] - Timeout in ms (default: 10000, use 0 to disable)
|
||||
* @returns {Promise<Response>} - The fetch response
|
||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||
* @throws {NetworkError} - If network error or timeout occurs
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
||||
|
||||
// Ensure credentials are included for cookie-based auth
|
||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||
|
||||
while (true) {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
|
||||
} catch (error) {
|
||||
// Handle network errors and timeouts
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new NetworkError('Request timed out', error)
|
||||
}
|
||||
if (error.name === 'AbortError') {
|
||||
// Re-throw abort errors as-is (user-initiated cancellation)
|
||||
throw error
|
||||
}
|
||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||
throw new NetworkError('Unable to connect to server', error)
|
||||
}
|
||||
throw new NetworkError(error.message || 'Network error', error)
|
||||
}
|
||||
|
||||
// Check for auth errors (401/403)
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Try to parse the response to get the iframe URL
|
||||
let authInfo = null
|
||||
try {
|
||||
const data = await response.clone().json()
|
||||
authInfo = data.auth
|
||||
} catch {
|
||||
// If we can't parse JSON, no iframe available
|
||||
}
|
||||
|
||||
// Authenticate via iframe (only in top-level window)
|
||||
if (authInfo?.iframe && window === window.top) {
|
||||
// Show auth iframe (or wait for existing one) and retry on success
|
||||
// showAuthIframe returns existing promise if iframe is already open
|
||||
await showAuthIframe(authInfo.iframe)
|
||||
continue // Retry the original request
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for JSON API calls.
|
||||
* Automatically sets Accept and Content-Type headers.
|
||||
* Returns parsed JSON directly if response is ok, throws ApiError otherwise.
|
||||
*
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @returns {Promise<any>} - Parsed JSON response
|
||||
* @throws {ApiError} - If response is not ok
|
||||
* @throws {NetworkError} - If network error or timeout occurs
|
||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||
*/
|
||||
export async function apiJson(url, options = {}) {
|
||||
const fetchOptions = { ...options }
|
||||
|
||||
// Set default headers, allowing caller overrides
|
||||
fetchOptions.headers = {
|
||||
'Accept': 'application/json',
|
||||
...fetchOptions.headers,
|
||||
}
|
||||
|
||||
// Set Content-Type for requests with JSON body
|
||||
if (fetchOptions.body && typeof fetchOptions.body === 'object' && !(fetchOptions.body instanceof FormData)) {
|
||||
fetchOptions.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...fetchOptions.headers,
|
||||
}
|
||||
fetchOptions.body = JSON.stringify(fetchOptions.body)
|
||||
}
|
||||
|
||||
const response = await apiFetch(url, fetchOptions)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(url, response, data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple JSON fetch without auto-auth iframe handling.
|
||||
* Use this in contexts where showing an auth iframe would be inappropriate
|
||||
* (e.g., inside the auth iframe itself).
|
||||
*
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @returns {Promise<any>} - Parsed JSON response
|
||||
* @throws {ApiError} - If response is not ok
|
||||
*/
|
||||
export async function fetchJson(url, options = {}) {
|
||||
const fetchOptions = {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(url, response, data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an error to a user-friendly message.
|
||||
* @param {Error} error - The error to convert
|
||||
* @returns {string} - User-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error) {
|
||||
if (error instanceof NetworkError) {
|
||||
return error.message
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return error.message
|
||||
}
|
||||
if (error.name === 'TimeoutError') {
|
||||
return 'Request timed out'
|
||||
}
|
||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||
return 'Unable to connect to server'
|
||||
}
|
||||
return error.message || 'An error occurred'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should show a toast to the user.
|
||||
* @param {Error} error - The error to check
|
||||
* @returns {boolean} - Whether to show a toast
|
||||
*/
|
||||
export function shouldShowErrorToast(error) {
|
||||
// Don't show toast for user cancellations
|
||||
if (error instanceof AuthCancelledError) return false
|
||||
if (error.name === 'AbortError') return false
|
||||
// Don't show toast for 401/403 errors - the auth iframe will handle these
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API caller with error handling (toast + console.error).
|
||||
* Wraps apiJson calls with consistent error handling for apps.
|
||||
*
|
||||
* @param {Function} showMessage - Function to show toast messages: (message, type, duration) => void
|
||||
* @returns {Function} - Wrapped apiJson that handles errors
|
||||
*/
|
||||
export function createApiCaller(showMessage) {
|
||||
/**
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @returns {Promise<any>} - Parsed JSON response, or undefined on error
|
||||
*/
|
||||
return async function apiCall(url, options = {}) {
|
||||
try {
|
||||
return await apiJson(url, options)
|
||||
} catch (error) {
|
||||
if (!shouldShowErrorToast(error)) {
|
||||
throw error
|
||||
}
|
||||
// Log full error details
|
||||
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
|
||||
// Show user-friendly toast
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default apiFetch
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { showAuthIframe } from '@/utils/api'
|
||||
import { showAuthIframe } from 'paskia'
|
||||
|
||||
// Generic path normalizer: if an auth_host is configured and differs from current
|
||||
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
|
||||
|
||||
@@ -90,7 +90,9 @@ export default defineConfig(({ command }) => ({
|
||||
}
|
||||
].filter(Boolean),
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
}
|
||||
},
|
||||
base: '/',
|
||||
server: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,131 @@
|
||||
# Paskia
|
||||
|
||||

|
||||
|
||||
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
|
||||
|
||||
## Installation
|
||||
|
||||
### NPM
|
||||
|
||||
No framework dependencies. Works with any framework (Vue, React, Svelte, etc.) or vanilla JS. Typescript typing included.
|
||||
|
||||
```sh
|
||||
npm install paskia
|
||||
```
|
||||
|
||||
```js
|
||||
import { ... } from 'paskia'
|
||||
```
|
||||
|
||||
### Plain JavaScript
|
||||
|
||||
Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) first and host yourself. No Node needed.
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import { ... } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
|
||||
</script>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Session Validation
|
||||
|
||||
Refresh session and track its validity with automatic polling. Pauses on lack of user activity to avoid useless traffic and to allow session expiry even when the page is left open but idle. This monitors that the same account stays logged in but doesn't do any permission checks.
|
||||
|
||||
```js
|
||||
import { SessionValidator } from 'paskia'
|
||||
|
||||
const validator = new SessionValidator(
|
||||
() => currentUser?.uuid, // getter for current user ID that we track
|
||||
(error) => handleSessionLost(error) // callback when session is lost
|
||||
)
|
||||
|
||||
validator.start() // call at your app startup/login
|
||||
validator.stop() // stop the system (optional)
|
||||
```
|
||||
|
||||
### API Fetch Utilities
|
||||
|
||||
Enhanced fetch functions with automatic error handling and authentication retry:
|
||||
|
||||
```js
|
||||
import { apiJson, apiFetch } from 'paskia'
|
||||
|
||||
// JSON API calls with automatic auth handling
|
||||
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
|
||||
|
||||
// Raw fetch with auth handling
|
||||
const response = await apiFetch('/api/endpoint')
|
||||
```
|
||||
|
||||
When a 401/403 response includes an auth iframe URL, the request automatically pauses, displays the authentication UI, and retries upon success. In case this is not needed, use standard `fetch` or our `fetchJson`.
|
||||
|
||||
The JSON variants set headers automatically, with body and response in JSON.
|
||||
|
||||
### 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.
|
||||
|
||||
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
|
||||
|
||||
```js
|
||||
import { showAuthIframe, AuthCancelledError } from 'paskia'
|
||||
|
||||
const response = await fetch('/api/protected')
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const data = await response.json()
|
||||
if (data.auth?.iframe) {
|
||||
await showAuthIframe(data.auth.iframe) // Raises AuthCancelledError if the user cancels
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This resolves after the user authenticates (possibly with another account than previously), and you should usually retry the original API request. Note that successful authentication doesn't guarantee that the user still has rights to what originally failed.
|
||||
|
||||
### Shared Blur Backdrop
|
||||
|
||||
The authentication dialog displays with a blur backdrop (z-index 1099). The auth iframe uses z-index 9999. Your app dialogs should use z-index 1100–9998 to appear above the backdrop but below authentication.
|
||||
|
||||
The backdrop is also reusable/refcounted, so you can keep consistent visuals for your own dialogs:
|
||||
|
||||
```js
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
|
||||
holdGlobalBackdrop()
|
||||
try {
|
||||
await your.own.dialog()
|
||||
} finally {
|
||||
releaseGlobalBackdrop()
|
||||
}
|
||||
```
|
||||
|
||||
The backdrop only disappears after all holders have released it.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### AuthCancelledError (apiFetch, apiJson, showAuthIframe)
|
||||
|
||||
If the user clicks Back in the authentication dialog, refusing to authenticate, `AuthCancelledError` is risen (as a response to postMessage from the iframe). The dialog closes as expected and it is up to the app how to continue from there.
|
||||
|
||||
- Do nothing if the app can continue despite the failed operation (no UI notification needed)
|
||||
- Display a simple Access Denied page with suggestion/button to reload the page to try again
|
||||
|
||||
Do not retry automatically.
|
||||
|
||||
### UI feedback
|
||||
|
||||
A set of small utilities are available for determining whether the user needs a notification and to format the error message.
|
||||
|
||||
```js
|
||||
import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia'
|
||||
|
||||
try {
|
||||
await apiJson('/api/action')
|
||||
} catch (e) {
|
||||
if (shouldShowErrorToast(e)) {
|
||||
your.message.display(getUserFriendlyErrorMessage(e))
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "paskia",
|
||||
"version": "0.1.2",
|
||||
"description": "Paskia authentication utilities for JavaScript",
|
||||
"type": "module",
|
||||
"main": "./dist/paskia.js",
|
||||
"types": "./dist/paskia.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/paskia.d.ts",
|
||||
"import": "./dist/paskia.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-dts": "^4.5.4"
|
||||
},
|
||||
"keywords": [
|
||||
"auth",
|
||||
"authentication",
|
||||
"paskia"
|
||||
],
|
||||
"license": "Unlicense"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { showAuthIframe, AuthCancelledError } from './overlay'
|
||||
|
||||
export { AuthCancelledError }
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 1000
|
||||
|
||||
export interface ApiFetchOptions extends RequestInit {
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface FetchJsonOptions extends Omit<RequestInit, 'body'> {
|
||||
timeout?: number
|
||||
body?: BodyInit | Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly url: string
|
||||
readonly status: number
|
||||
readonly statusText: string
|
||||
readonly data: unknown
|
||||
|
||||
constructor(url: string, response: Response, data: unknown) {
|
||||
super((data as { detail?: string })?.detail || `Request failed: ${response.status}`)
|
||||
this.name = 'ApiError'
|
||||
this.url = url
|
||||
this.status = response.status
|
||||
this.statusText = response.statusText
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
readonly originalError: Error | null
|
||||
|
||||
constructor(message: string, originalError: Error | null = null) {
|
||||
super(message)
|
||||
this.name = 'NetworkError'
|
||||
this.originalError = originalError
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
|
||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||
|
||||
while (true) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {...fetchOptions, signal: timeout ? AbortSignal.timeout(timeout) : undefined})
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
if (err.name === 'TimeoutError') {
|
||||
throw new NetworkError('Request timed out', err)
|
||||
}
|
||||
if (err.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
|
||||
throw new NetworkError('Unable to connect to server', err)
|
||||
}
|
||||
throw new NetworkError(err.message || 'Network error', err)
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
let data: { auth?: { iframe?: string } } | null = null
|
||||
try {
|
||||
data = await response.clone().json()
|
||||
} catch {}
|
||||
if (data?.auth?.iframe && window === window.top) {
|
||||
await showAuthIframe(data.auth.iframe)
|
||||
continue // Retry the original request after successful auth
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
type FetchFn = (url: string, options?: RequestInit) => Promise<Response>
|
||||
|
||||
export async function apiJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||
return fetchJson<T>(url, options, apiFetch)
|
||||
}
|
||||
|
||||
export async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}, fetchFn: FetchFn = fetch): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined
|
||||
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
body = JSON.stringify(options.body)
|
||||
} else {
|
||||
body = options.body as BodyInit
|
||||
}
|
||||
|
||||
const opt: RequestInit = { ...options, headers, body }
|
||||
|
||||
const response = await fetchFn(url, opt)
|
||||
const data = await response.json() as T
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(url, response, data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function getUserFriendlyErrorMessage(error: Error): string {
|
||||
if (error instanceof NetworkError) return error.message
|
||||
if (error instanceof ApiError) return error.message
|
||||
if (error.name === 'TimeoutError') return 'Request timed out'
|
||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||
return 'Unable to connect to server'
|
||||
}
|
||||
return error.message || 'An error occurred'
|
||||
}
|
||||
|
||||
export function shouldShowErrorToast(error: Error): boolean {
|
||||
if (error instanceof AuthCancelledError) return false
|
||||
if (error.name === 'AbortError') return false
|
||||
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
type ShowMessageFn = (message: string, type: string, duration: number) => void
|
||||
|
||||
export function createApiCaller(showMessage: ShowMessageFn) {
|
||||
return async function apiCall<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||
try {
|
||||
return await apiJson<T>(url, options)
|
||||
} catch (error) {
|
||||
if (!shouldShowErrorToast(error as Error)) {
|
||||
throw error
|
||||
}
|
||||
const err = error as Error
|
||||
console.error(`API error for ${url}:`, err instanceof ApiError ? { status: err.status, statusText: err.statusText, data: err.data } : err)
|
||||
showMessage(getUserFriendlyErrorMessage(err), 'error', 4000)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default apiFetch
|
||||
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
ApiError,
|
||||
NetworkError,
|
||||
AuthCancelledError,
|
||||
apiFetch,
|
||||
apiJson,
|
||||
fetchJson,
|
||||
getUserFriendlyErrorMessage,
|
||||
shouldShowErrorToast,
|
||||
createApiCaller,
|
||||
} from './fetch'
|
||||
|
||||
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
|
||||
|
||||
export {
|
||||
holdGlobalBackdrop,
|
||||
releaseGlobalBackdrop,
|
||||
isAuthIframeOpen,
|
||||
hideAuthIframe,
|
||||
showAuthIframe,
|
||||
createAuthIframe,
|
||||
removeAuthIframe,
|
||||
} from './overlay'
|
||||
|
||||
export { SessionValidator } from './validate'
|
||||
@@ -0,0 +1,166 @@
|
||||
const AUTH_IFRAME_ID = 'paskia-iframe'
|
||||
const STYLES_ID = 'paskia-dialog'
|
||||
const STYLES_TEXT = `\
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1099;
|
||||
background: transparent;
|
||||
backdrop-filter: blur(0) brightness(1);
|
||||
-webkit-backdrop-filter: blur(0) brightness(1);
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
body.paskia-backdrop::before {
|
||||
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
|
||||
backdrop-filter: blur(.2rem) brightness(0.5);
|
||||
visibility: visible;
|
||||
}
|
||||
body.paskia-backdrop {
|
||||
overflow: auto;
|
||||
}
|
||||
#${AUTH_IFRAME_ID} {
|
||||
border: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
color-scheme: auto;
|
||||
background: transparent;
|
||||
}
|
||||
`
|
||||
|
||||
let authIframe: HTMLIFrameElement | null = null
|
||||
let authPromise: Promise<void> | null = null
|
||||
let authResolve: (() => void) | null = null
|
||||
let authReject: ((error: Error) => void) | null = null
|
||||
let messageListenerInstalled = false
|
||||
let backdropHolders = 0
|
||||
|
||||
function injectStyles(): void {
|
||||
if (document.getElementById(STYLES_ID)) return
|
||||
const style = document.createElement('style')
|
||||
style.id = STYLES_ID
|
||||
style.textContent = STYLES_TEXT
|
||||
document.head.insertBefore(style, document.head.firstChild)
|
||||
}
|
||||
|
||||
export class AuthCancelledError extends Error {
|
||||
constructor() {
|
||||
super('Authentication cancelled')
|
||||
this.name = 'AuthCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
export function holdGlobalBackdrop(): void {
|
||||
backdropHolders++
|
||||
document.body.classList.add('paskia-backdrop')
|
||||
}
|
||||
|
||||
export function releaseGlobalBackdrop(): void {
|
||||
backdropHolders = Math.max(0, backdropHolders - 1)
|
||||
if (backdropHolders === 0) {
|
||||
document.body.classList.remove('paskia-backdrop')
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthIframeOpen(): boolean {
|
||||
return !!document.getElementById(AUTH_IFRAME_ID)
|
||||
}
|
||||
|
||||
export function hideAuthIframe(): void {
|
||||
if (authIframe) {
|
||||
authIframe.remove()
|
||||
authIframe = null
|
||||
releaseGlobalBackdrop()
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthMessage(event: MessageEvent): void {
|
||||
const data = event.data as { type?: string }
|
||||
if (!data?.type) return
|
||||
|
||||
switch (data.type) {
|
||||
case 'auth-success':
|
||||
hideAuthIframe()
|
||||
if (authResolve) {
|
||||
authResolve()
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
}
|
||||
break
|
||||
|
||||
case 'auth-back':
|
||||
hideAuthIframe()
|
||||
if (authReject) {
|
||||
authReject(new AuthCancelledError())
|
||||
authPromise = null
|
||||
authResolve = null
|
||||
authReject = null
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMessageListener(): void {
|
||||
if (messageListenerInstalled) return
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
messageListenerInstalled = true
|
||||
}
|
||||
}
|
||||
|
||||
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
|
||||
injectStyles()
|
||||
ensureMessageListener()
|
||||
|
||||
if (authPromise) return authPromise
|
||||
|
||||
if (document.getElementById(AUTH_IFRAME_ID)) {
|
||||
authPromise = new Promise((resolve, reject) => {
|
||||
authResolve = resolve
|
||||
authReject = reject
|
||||
})
|
||||
return authPromise
|
||||
}
|
||||
|
||||
authPromise = new Promise((resolve, reject) => {
|
||||
authResolve = resolve
|
||||
authReject = reject
|
||||
})
|
||||
|
||||
hideAuthIframe()
|
||||
holdGlobalBackdrop()
|
||||
|
||||
authIframe = document.createElement('iframe')
|
||||
authIframe.id = AUTH_IFRAME_ID
|
||||
authIframe.title = title
|
||||
authIframe.src = iframeUrl
|
||||
document.body.appendChild(authIframe)
|
||||
|
||||
return authPromise
|
||||
}
|
||||
|
||||
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
|
||||
injectStyles()
|
||||
const existing = document.getElementById(AUTH_IFRAME_ID)
|
||||
if (existing) existing.remove()
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.id = AUTH_IFRAME_ID
|
||||
iframe.title = title
|
||||
iframe.src = iframeUrl
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
return iframe
|
||||
}
|
||||
|
||||
export function removeAuthIframe(): void {
|
||||
const iframe = document.getElementById(AUTH_IFRAME_ID)
|
||||
if (iframe) iframe.remove()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { apiJson } from './fetch'
|
||||
|
||||
const POLL_INTERVAL = 60 * 1000
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
|
||||
export class SessionValidator {
|
||||
private userUuidGetter: () => string | undefined
|
||||
private onSessionLost: (error: Error) => void
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private active = false
|
||||
|
||||
constructor(userUuidGetter: () => string | undefined, onSessionLost: (error: Error) => void) {
|
||||
this.userUuidGetter = userUuidGetter
|
||||
this.onSessionLost = onSessionLost
|
||||
this.resetIdleTimer = this.resetIdleTimer.bind(this)
|
||||
}
|
||||
|
||||
resetIdleTimer(): void {
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer)
|
||||
if (!this.active) this.startPolling()
|
||||
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
|
||||
}
|
||||
|
||||
async validate(): Promise<void> {
|
||||
try {
|
||||
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
|
||||
const newUuid = data.ctx?.user?.uuid
|
||||
if (newUuid !== this.userUuidGetter()) {
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'NetworkError') {
|
||||
this.stopPolling()
|
||||
this.onSessionLost(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startPolling(): void {
|
||||
if (this.active) return
|
||||
this.active = true
|
||||
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
this.active = false
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
window.addEventListener('pointermove', this.resetIdleTimer)
|
||||
window.addEventListener('pointerdown', this.resetIdleTimer)
|
||||
this.resetIdleTimer()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
window.removeEventListener('pointermove', this.resetIdleTimer)
|
||||
window.removeEventListener('pointerdown', this.resetIdleTimer)
|
||||
if (this.idleTimer) clearTimeout(this.idleTimer)
|
||||
this.stopPolling()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import dts from 'vite-plugin-dts'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [dts({ rollupTypes: true })],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
fileName: 'paskia',
|
||||
formats: ['es'],
|
||||
},
|
||||
},
|
||||
})
|
||||
+2
-2
@@ -198,7 +198,6 @@ class JsonlStore:
|
||||
if not diff:
|
||||
return
|
||||
self._pending_changes.append(create_change_record(action, version, diff, user))
|
||||
self._previous_builtins = copy.deepcopy(current)
|
||||
|
||||
# Log the change with user display name if available
|
||||
user_display = None
|
||||
@@ -210,7 +209,8 @@ class JsonlStore:
|
||||
except (ValueError, KeyError):
|
||||
user_display = user
|
||||
|
||||
log_change(action, diff, user_display)
|
||||
log_change(action, diff, user_display, self._previous_builtins)
|
||||
self._previous_builtins = copy.deepcopy(current)
|
||||
|
||||
@contextmanager
|
||||
def transaction(
|
||||
|
||||
+128
-43
@@ -26,8 +26,7 @@ _RESET = "\033[0m"
|
||||
_DIM = "\033[2m"
|
||||
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
||||
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
||||
_REPLACE = "\033[0;33m" # Yellow for replacements
|
||||
_DELETE = "\033[0;31m" # Red for deletions
|
||||
_DELETE = "\033[1;31m" # Red for deletions
|
||||
_ADD = "\033[0;32m" # Green for additions
|
||||
_ACTION = "\033[1;34m" # Bold blue for action name
|
||||
_USER = "\033[0;34m" # Blue for user display
|
||||
@@ -93,18 +92,34 @@ def _format_path(path: list[str], use_color: bool) -> str:
|
||||
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
||||
|
||||
|
||||
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
||||
"""Get a nested value from a dict by path, or None if not found."""
|
||||
if data is None:
|
||||
return None
|
||||
current = data
|
||||
for key in path:
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
return None
|
||||
current = current[key]
|
||||
return current
|
||||
|
||||
|
||||
def _collect_changes(
|
||||
diff: dict, path: list[str], changes: list[tuple[str, list[str], Any, Any | None]]
|
||||
diff: dict,
|
||||
path: list[str],
|
||||
changes: list[tuple[str, list[str], Any]],
|
||||
previous: dict | None,
|
||||
) -> None:
|
||||
"""
|
||||
Recursively collect changes from a diff into a flat list.
|
||||
|
||||
Each change is a tuple of (change_type, path, new_value, old_value).
|
||||
change_type is one of: 'set', 'replace', 'delete'
|
||||
Each change is a tuple of (change_type, path, new_value).
|
||||
change_type is one of: 'add', 'update', 'delete'
|
||||
"""
|
||||
if not isinstance(diff, dict):
|
||||
# Leaf value - this is a set operation
|
||||
changes.append(("set", path, diff, None))
|
||||
# Leaf value - check if it existed before
|
||||
existed = _get_nested(previous, path) is not None
|
||||
changes.append(("update" if existed else "add", path, diff))
|
||||
return
|
||||
|
||||
for key, value in diff.items():
|
||||
@@ -112,72 +127,136 @@ def _collect_changes(
|
||||
# $delete contains a list of keys to delete
|
||||
if isinstance(value, list):
|
||||
for deleted_key in value:
|
||||
changes.append(("delete", path + [str(deleted_key)], None, None))
|
||||
changes.append(("delete", path + [str(deleted_key)], None))
|
||||
else:
|
||||
changes.append(("delete", path + [str(value)], None, None))
|
||||
changes.append(("delete", path + [str(value)], None))
|
||||
|
||||
elif key == "$replace":
|
||||
# $replace contains the new value for this path
|
||||
# $replace replaces the entire collection at this path
|
||||
# We need to track what was added and what was deleted
|
||||
old_collection = _get_nested(previous, path)
|
||||
old_keys = (
|
||||
set(old_collection.keys())
|
||||
if isinstance(old_collection, dict)
|
||||
else set()
|
||||
)
|
||||
new_keys = set(value.keys()) if isinstance(value, dict) else set()
|
||||
|
||||
# Items that existed before but not in new = deleted
|
||||
for deleted_key in old_keys - new_keys:
|
||||
changes.append(("delete", path + [str(deleted_key)], None))
|
||||
|
||||
# Items in new collection
|
||||
if isinstance(value, dict):
|
||||
# Replacing with a dict - show each key as a replacement
|
||||
for rkey, rval in value.items():
|
||||
changes.append(("replace", path + [str(rkey)], rval, None))
|
||||
if not value:
|
||||
# Empty replacement - clearing the collection
|
||||
changes.append(("replace", path, {}, None))
|
||||
else:
|
||||
changes.append(("replace", path, value, None))
|
||||
existed = rkey in old_keys
|
||||
changes.append(
|
||||
("update" if existed else "add", path + [str(rkey)], rval)
|
||||
)
|
||||
elif value or not old_keys:
|
||||
# Non-dict replacement or empty replacement with nothing before
|
||||
changes.append(
|
||||
("update" if old_collection is not None else "add", path, value)
|
||||
)
|
||||
|
||||
elif key.startswith("$"):
|
||||
# Other special operations (future-proofing)
|
||||
changes.append(("set", path, {key: value}, None))
|
||||
changes.append(("add", path, {key: value}))
|
||||
|
||||
else:
|
||||
# Regular nested key
|
||||
_collect_changes(value, path + [str(key)], changes)
|
||||
# Regular nested key - check if this item existed before
|
||||
new_path = path + [str(key)]
|
||||
existed = _get_nested(previous, new_path) is not None
|
||||
if existed:
|
||||
# Item exists - recurse to show specific field changes
|
||||
_collect_changes(value, new_path, changes, previous)
|
||||
else:
|
||||
# New item - record as add with full value, don't recurse
|
||||
changes.append(("add", new_path, value))
|
||||
|
||||
|
||||
def _format_change_line(
|
||||
def _format_change_lines(
|
||||
change_type: str, path: list[str], value: Any, use_color: bool
|
||||
) -> str:
|
||||
"""Format a single change as a one-line string."""
|
||||
path_str = _format_path(path, use_color)
|
||||
value_str = _format_value(value, use_color)
|
||||
|
||||
) -> list[str]:
|
||||
"""Format a single change as one or more lines."""
|
||||
if change_type == "delete":
|
||||
if not use_color:
|
||||
return [f" {'.'.join(path)} ✗"]
|
||||
if len(path) == 1:
|
||||
return [f" {_DELETE}{path[0]} ✗{_RESET}"]
|
||||
prefix = ".".join(path[:-1])
|
||||
final = path[-1]
|
||||
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
||||
|
||||
if change_type == "add":
|
||||
# New item being created - only final element in green
|
||||
# For dict values, show children on separate indented lines
|
||||
if isinstance(value, dict) and value:
|
||||
lines = []
|
||||
# First line: path with green final element and grey =
|
||||
if not use_color:
|
||||
lines.append(f" {'.'.join(path)} =")
|
||||
elif len(path) == 1:
|
||||
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
|
||||
else:
|
||||
prefix = ".".join(path[:-1])
|
||||
final = path[-1]
|
||||
lines.append(
|
||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
||||
)
|
||||
# Child lines: indented key: value, with aligned values
|
||||
max_key_len = max(len(k) for k in value.keys())
|
||||
field_width = max(max_key_len, 12) # minimum 12 chars
|
||||
for k, v in value.items():
|
||||
v_str = _format_value(v, use_color)
|
||||
padding = " " * (field_width - len(k))
|
||||
if use_color:
|
||||
return f" ❌ {path_str}"
|
||||
return f" - {path_str}"
|
||||
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
|
||||
else:
|
||||
lines.append(f" {k}:{padding} {v_str}")
|
||||
return lines
|
||||
else:
|
||||
value_str = _format_value(value, use_color)
|
||||
if not use_color:
|
||||
return [f" {'.'.join(path)} = {value_str}"]
|
||||
if len(path) == 1:
|
||||
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
|
||||
prefix = ".".join(path[:-1])
|
||||
final = path[-1]
|
||||
return [
|
||||
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
||||
]
|
||||
|
||||
if change_type == "replace":
|
||||
# update: Existing item being updated - normal path colors
|
||||
value_str = _format_value(value, use_color)
|
||||
path_str = _format_path(path, use_color)
|
||||
if use_color:
|
||||
return f" {_REPLACE}⟳{_RESET} {path_str} {_DIM}={_RESET} {value_str}"
|
||||
return f" ~ {path_str} = {value_str}"
|
||||
|
||||
# Default: set/add
|
||||
if use_color:
|
||||
return f" {_ADD}+{_RESET} {path_str} {_DIM}={_RESET} {value_str}"
|
||||
return f" + {path_str} = {value_str}"
|
||||
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
||||
return [f" {path_str} = {value_str}"]
|
||||
|
||||
|
||||
def format_diff(diff: dict) -> list[str]:
|
||||
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
|
||||
"""
|
||||
Format a JSON diff as human-readable lines.
|
||||
|
||||
Args:
|
||||
diff: The JSON diff dict
|
||||
previous: The previous state dict (for determining add vs update)
|
||||
|
||||
Returns a list of formatted lines (without newlines).
|
||||
Single changes return one line, multiple changes return multiple lines.
|
||||
"""
|
||||
use_color = _use_color()
|
||||
changes: list[tuple[str, list[str], Any, Any | None]] = []
|
||||
_collect_changes(diff, [], changes)
|
||||
changes: list[tuple[str, list[str], Any]] = []
|
||||
_collect_changes(diff, [], changes, previous)
|
||||
|
||||
if not changes:
|
||||
return []
|
||||
|
||||
# Format each change
|
||||
lines = []
|
||||
for change_type, path, value, _ in changes:
|
||||
lines.append(_format_change_line(change_type, path, value, use_color))
|
||||
for change_type, path, value in changes:
|
||||
lines.extend(_format_change_lines(change_type, path, value, use_color))
|
||||
|
||||
return lines
|
||||
|
||||
@@ -198,7 +277,12 @@ def format_action_header(action: str, user_display: str | None = None) -> str:
|
||||
return action
|
||||
|
||||
|
||||
def log_change(action: str, diff: dict, user_display: str | None = None) -> None:
|
||||
def log_change(
|
||||
action: str,
|
||||
diff: dict,
|
||||
user_display: str | None = None,
|
||||
previous: dict | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log a database change with pretty-printed diff.
|
||||
|
||||
@@ -206,9 +290,10 @@ def log_change(action: str, diff: dict, user_display: str | None = None) -> None
|
||||
action: The action name (e.g., "login", "admin:delete_user")
|
||||
diff: The JSON diff dict
|
||||
user_display: Optional display name of the user who performed the action
|
||||
previous: The previous state dict (for determining add vs update)
|
||||
"""
|
||||
header = format_action_header(action, user_display)
|
||||
diff_lines = format_diff(diff)
|
||||
diff_lines = format_diff(diff, previous)
|
||||
|
||||
if not diff_lines:
|
||||
logger.info(header)
|
||||
|
||||
@@ -517,16 +517,18 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None)
|
||||
update_session(key, host=host, ctx=ctx)
|
||||
|
||||
|
||||
def delete_session(key: str, *, ctx: SessionContext | None = None) -> None:
|
||||
def delete_session(
|
||||
key: str, *, ctx: SessionContext | None = None, action: str = "delete_session"
|
||||
) -> None:
|
||||
"""Delete a session.
|
||||
|
||||
The acting user should be logged via ctx.
|
||||
For user logout, pass ctx of the user's session.
|
||||
For user logout, pass ctx of the user's session and action="logout".
|
||||
For admin terminating a session, pass admin's ctx.
|
||||
"""
|
||||
if key not in _db.sessions:
|
||||
raise ValueError("Session not found")
|
||||
with _db.transaction("delete_session", ctx):
|
||||
with _db.transaction(action, ctx):
|
||||
del _db.sessions[key]
|
||||
|
||||
|
||||
@@ -554,6 +556,7 @@ def create_reset_token(
|
||||
token_type: str,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
user: str | None = None,
|
||||
) -> None:
|
||||
"""Create a reset token from a passphrase.
|
||||
|
||||
@@ -561,13 +564,14 @@ def create_reset_token(
|
||||
For self-service (user creating own recovery link), pass user's ctx.
|
||||
For admin operations, pass admin's ctx.
|
||||
For system operations (bootstrap), pass neither to log no user.
|
||||
For API operations where ctx is not available but user is known, pass user.
|
||||
"""
|
||||
key = _reset_key(passphrase)
|
||||
if key in _db.reset_tokens:
|
||||
raise ValueError("Reset token already exists")
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
with _db.transaction("create_reset_token", ctx):
|
||||
with _db.transaction("create_reset_token", ctx, user=user):
|
||||
_db.reset_tokens[key] = ResetToken(
|
||||
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
||||
)
|
||||
|
||||
@@ -7,12 +7,12 @@ from urllib.parse import urlparse
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from uvicorn import Config, Server
|
||||
from uvicorn import run as uvicorn_run
|
||||
|
||||
from paskia import globals as _globals
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.config import PaskiaConfig
|
||||
from paskia.db.background import flush
|
||||
from paskia.fastapi import app as fastapi_app
|
||||
from paskia.fastapi import reset as reset_cmd
|
||||
from paskia.util import startupbox
|
||||
from paskia.util.hostutil import normalize_origin
|
||||
@@ -188,7 +188,7 @@ def main():
|
||||
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
||||
|
||||
run_kwargs: dict = {
|
||||
"log_level": "info",
|
||||
"log_level": "warning", # Suppress startup messages; we use custom logging
|
||||
"access_log": False, # We use custom AccessLogMiddleware instead
|
||||
}
|
||||
|
||||
@@ -199,8 +199,6 @@ def main():
|
||||
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
||||
run_kwargs["reload"] = True
|
||||
run_kwargs["reload_dirs"] = ["paskia"]
|
||||
# Suppress uvicorn startup messages in dev mode
|
||||
run_kwargs["log_level"] = "warning"
|
||||
|
||||
async def async_main():
|
||||
await _globals.init(
|
||||
@@ -220,10 +218,18 @@ def main():
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
for ep in endpoints:
|
||||
tg.create_task(
|
||||
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
|
||||
Server(
|
||||
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
|
||||
).serve()
|
||||
)
|
||||
elif devmode:
|
||||
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
|
||||
ep = endpoints[0]
|
||||
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
|
||||
else:
|
||||
server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
|
||||
server = Server(
|
||||
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
||||
)
|
||||
await server.serve()
|
||||
|
||||
try:
|
||||
|
||||
@@ -127,7 +127,7 @@ async def admin_create_org(
|
||||
db.create_org(org, ctx=ctx)
|
||||
# Grant requested permissions to the new org
|
||||
for perm in permissions:
|
||||
db.add_permission_to_org(str(org.uuid), perm)
|
||||
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
|
||||
|
||||
return {"uuid": str(org.uuid)}
|
||||
|
||||
@@ -706,7 +706,7 @@ async def admin_delete_user_session(
|
||||
if not target_session or target_session.user_uuid != user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
db.delete_session(session_id, ctx=ctx)
|
||||
db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
|
||||
|
||||
# Check if admin terminated their own session
|
||||
current_terminated = session_id == auth
|
||||
|
||||
@@ -78,7 +78,7 @@ async def validate_token(
|
||||
try:
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
perm,
|
||||
" ".join(perm).split(),
|
||||
host=request.headers.get("host"),
|
||||
max_age=max_age,
|
||||
)
|
||||
@@ -94,6 +94,7 @@ async def validate_token(
|
||||
ip=request.client.host if request.client else "",
|
||||
user_agent=request.headers.get("user-agent") or "",
|
||||
expiry=expires(),
|
||||
ctx=ctx,
|
||||
)
|
||||
session.set_session_cookie(response, auth)
|
||||
renewed = True
|
||||
@@ -130,7 +131,10 @@ async def forward_authentication(
|
||||
"""
|
||||
try:
|
||||
ctx = await authz.verify(
|
||||
auth, perm, host=request.headers.get("host"), max_age=max_age
|
||||
auth,
|
||||
" ".join(perm).split(),
|
||||
host=request.headers.get("host"),
|
||||
max_age=max_age,
|
||||
)
|
||||
# Build permission scopes for Remote-Groups header
|
||||
role_permissions = (
|
||||
@@ -248,7 +252,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not ctx:
|
||||
return {"message": "Already logged out"}
|
||||
with suppress(Exception):
|
||||
db.delete_session(auth, ctx=ctx)
|
||||
db.delete_session(auth, ctx=ctx, action="logout")
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from paskia.fastapi.logging import log_permission_denied
|
||||
from paskia.util import permutil, sessionutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -93,20 +94,14 @@ async def verify(
|
||||
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
||||
|
||||
if not match(ctx, perm):
|
||||
# Determine which permissions are missing for clearer diagnostics
|
||||
effective_scopes = (
|
||||
{p.scope for p in (ctx.permissions or [])}
|
||||
if ctx.permissions
|
||||
else set(ctx.role.permissions or [])
|
||||
)
|
||||
missing = sorted(set(perm) - effective_scopes)
|
||||
logger.warning(
|
||||
"Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
|
||||
getattr(ctx.user, "uuid", "?"),
|
||||
getattr(ctx.role, "display_name", "?"),
|
||||
missing,
|
||||
perm,
|
||||
list(effective_scopes),
|
||||
log_permission_denied(
|
||||
ctx, perm, missing, require_all=(match == permutil.has_all)
|
||||
)
|
||||
raise AuthException(
|
||||
status_code=403, mode="forbidden", detail="Permission required"
|
||||
|
||||
+64
-21
@@ -4,8 +4,12 @@ import logging
|
||||
import sys
|
||||
import time
|
||||
from ipaddress import IPv6Address
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.db.structs import SessionContext
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
@@ -13,18 +17,24 @@ logger = logging.getLogger("paskia.access")
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||
_STATUS_OK = "\033[92m" # 2xx (bright green)
|
||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
||||
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
||||
_STATUS_SERVER_ERR = "\033[1;31m" # 5xx (bright red)
|
||||
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
||||
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
||||
_METHOD_WRITE = "\033[1;34m" # POST, PUT, DELETE, PATCH (bright blue)
|
||||
_HOST = "\033[1;30m" # hostname (dark grey)
|
||||
_PATH = "\033[0m" # path (default)
|
||||
_TIMING = "\033[2m" # timing (dim)
|
||||
_WS_OPEN = "\033[1;33m" # WebSocket connect (bright yellow)
|
||||
_WS_CLOSE = "\033[0;33m" # WebSocket disconnect (yellow)
|
||||
_WS_STATUS = "\033[1;30m" # WebSocket close status (dark grey)
|
||||
_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_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)
|
||||
_AUTHZ_ORG = "\033[34m" # User info (blue)
|
||||
_AUTHZ_NEEDS = "\033[1;38;5;231m" # Needs (brightest white)
|
||||
_AUTHZ_MISSING = "\033[1;31m" # Missing scope (bold red)
|
||||
_AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
|
||||
|
||||
|
||||
def format_ipv6_network(ip: str) -> str:
|
||||
@@ -41,8 +51,8 @@ def format_ipv6_network(ip: str) -> str:
|
||||
network_int >>= 16
|
||||
# Compress consecutive zero groups
|
||||
result = ":".join(groups) + "::"
|
||||
# Simplify leading zeros in groups and compress
|
||||
return str(IPv6Address(result + "0"))
|
||||
# Simplify leading zeros in groups and compress, then strip trailing ::
|
||||
return str(IPv6Address(result + "0")).removesuffix("::")
|
||||
except Exception:
|
||||
return ip
|
||||
|
||||
@@ -83,7 +93,7 @@ def format_access_log(
|
||||
use_color = sys.stderr.isatty()
|
||||
|
||||
# Format components with fixed widths for alignment
|
||||
ip = format_client_ip(client).ljust(15) # IPv4 max 15 chars
|
||||
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
|
||||
timing = f"{duration_ms:.0f}ms"
|
||||
method_padded = method.ljust(7) # Longest method is OPTIONS (7)
|
||||
|
||||
@@ -116,25 +126,39 @@ def _next_ws_id() -> int:
|
||||
return ws_id
|
||||
|
||||
|
||||
def log_ws_open(client: str, host: str, path: str) -> int:
|
||||
def log_ws_open(ws) -> int:
|
||||
"""Log WebSocket connection open. Returns connection ID for use in close."""
|
||||
use_color = sys.stderr.isatty()
|
||||
ws_id = _next_ws_id()
|
||||
|
||||
ip = format_client_ip(client).ljust(15)
|
||||
client = ws.client.host if ws.client else "-"
|
||||
host = ws.headers.get("host", "-")
|
||||
path = ws.url.path
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
if use_color:
|
||||
# 🔌 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 ""
|
||||
)
|
||||
else:
|
||||
prefix = f"WS+ {id_str}"
|
||||
host_str = host
|
||||
path_str = path
|
||||
origin_str = f" from {origin_host}" if show_origin else ""
|
||||
|
||||
logger.info(f"{ip} {prefix} {host_str}{path_str}")
|
||||
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
||||
return ws_id
|
||||
|
||||
|
||||
@@ -158,15 +182,12 @@ WS_CLOSE_CODES = {
|
||||
}
|
||||
|
||||
|
||||
def log_ws_close(
|
||||
client: str, ws_id: int, close_code: int | None, duration_ms: float
|
||||
) -> None:
|
||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||
"""Log WebSocket connection close with duration and status."""
|
||||
use_color = sys.stderr.isatty()
|
||||
|
||||
ip = format_client_ip(client).ljust(15)
|
||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
||||
timing = f"{duration_ms:.0f}ms"
|
||||
timing = f"{duration * 1000:.0f}ms"
|
||||
|
||||
# Convert close code to status text
|
||||
if close_code is None:
|
||||
@@ -184,7 +205,27 @@ def log_ws_close(
|
||||
status_str = status
|
||||
timing_str = timing
|
||||
|
||||
logger.info(f"{ip} {prefix} {status_str} {timing_str}")
|
||||
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
||||
|
||||
|
||||
def log_permission_denied(
|
||||
ctx: "SessionContext", required: list[str], missing: list[str], *, require_all: bool
|
||||
) -> None:
|
||||
"""Log permission denied with org, role, user and highlighted missing scopes."""
|
||||
missing_set = set(missing)
|
||||
scopes = " ".join(
|
||||
f"{_AUTHZ_MISSING}{s}✗{_RESET}"
|
||||
if s in missing_set
|
||||
else f"{_AUTHZ_GRANTED}{s}✓{_RESET}"
|
||||
for s in required
|
||||
)
|
||||
n = "" if len(required) == 1 else " all" if require_all else " any"
|
||||
logger.warning(
|
||||
f"{_AUTHZ_DENIED}Permission denied{_RESET} "
|
||||
f"{_AUTHZ_USER}{ctx.user.display_name}{_RESET} "
|
||||
f"{_AUTHZ_ORG}({ctx.org.display_name} {ctx.role.display_name}){_RESET} "
|
||||
f"{_AUTHZ_NEEDS}needs{n}:{_RESET} {scopes}"
|
||||
)
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
@@ -216,3 +257,5 @@ def configure_access_logging():
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
# Suppress watchfiles "X changes detected" INFO messages (keep WARNING for reload notification)
|
||||
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
|
||||
|
||||
@@ -20,6 +20,8 @@ from paskia.util import hostutil, passphrase, vitedev
|
||||
configure_access_logging()
|
||||
configure_db_logging()
|
||||
|
||||
_access_logger = logging.getLogger("paskia.access")
|
||||
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).parent.parent / "frontend-build",
|
||||
@@ -59,7 +61,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
if frontend.devmode:
|
||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
|
||||
|
||||
await frontend.load()
|
||||
await start_background()
|
||||
yield
|
||||
|
||||
+11
-37
@@ -17,10 +17,10 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
|
||||
from paskia import db, remoteauth
|
||||
from paskia.authsession import expires
|
||||
from paskia.fastapi.session import infodict
|
||||
from paskia.fastapi.wschat import authenticate_chat
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_and_login
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.util import hostutil, passphrase, pow, useragent
|
||||
from paskia.util import passphrase, pow, useragent
|
||||
|
||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -252,7 +252,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
|
||||
@app.websocket("/permit")
|
||||
@websocket_error_handler
|
||||
async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
"""Complete a remote authentication request using a 3-word pairing code.
|
||||
|
||||
This endpoint is called from the user's profile on the authenticating device.
|
||||
@@ -270,7 +270,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
7. Server sends {status: "success", message: "..."}
|
||||
"""
|
||||
|
||||
origin = validate_origin(ws)
|
||||
validate_origin(ws)
|
||||
|
||||
if remoteauth.instance is None:
|
||||
raise ValueError("Remote authentication is not available")
|
||||
@@ -310,56 +310,30 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
|
||||
# Handle authenticate request (no PoW needed - already validated during lookup)
|
||||
if msg.get("authenticate") and request is not None:
|
||||
cred, new_sign_count = await authenticate_chat(ws, origin)
|
||||
ctx = await authenticate_and_login(ws, auth)
|
||||
|
||||
# Create a session for the REQUESTING device
|
||||
assert cred.uuid is not None
|
||||
|
||||
session_token = None
|
||||
session_token = ctx.session.key
|
||||
reset_token = None
|
||||
|
||||
if request.action == "register":
|
||||
# For registration, create a reset token for device addition
|
||||
|
||||
token_str = passphrase.generate()
|
||||
expiry = expires()
|
||||
db.create_reset_token(
|
||||
user_uuid=cred.user_uuid,
|
||||
user_uuid=ctx.user.uuid,
|
||||
passphrase=token_str,
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
user=str(ctx.user.uuid),
|
||||
)
|
||||
reset_token = token_str
|
||||
# Also create a session so the device is logged in
|
||||
normalized_host = hostutil.normalize_host(request.host)
|
||||
session_token = db.login(
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
expiry=expires(),
|
||||
)
|
||||
else:
|
||||
# Default login action
|
||||
|
||||
normalized_host = hostutil.normalize_host(request.host)
|
||||
session_token = db.login(
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
expiry=expires(),
|
||||
)
|
||||
|
||||
# Complete the remote auth request (notifies the waiting device)
|
||||
cred = db.data().credentials[ctx.session.credential_uuid]
|
||||
completed = await remoteauth.instance.complete_request(
|
||||
token=request.key,
|
||||
session_token=session_token,
|
||||
user_uuid=cred.user_uuid,
|
||||
user_uuid=ctx.user.uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
reset_token=reset_token,
|
||||
)
|
||||
|
||||
+12
-35
@@ -1,13 +1,13 @@
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import expires, get_reset
|
||||
from paskia.authsession import get_reset
|
||||
from paskia.fastapi import authz, remote
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_chat, register_chat
|
||||
from paskia.fastapi.wschat import authenticate_and_login, register_chat
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import passphrase
|
||||
|
||||
# Create a FastAPI subapp for WebSocket endpoints
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -46,7 +46,7 @@ async def websocket_register_add(
|
||||
s = ctx.session
|
||||
|
||||
# Get user information and determine effective user_name for this registration
|
||||
user = db.data().users.get(user_uuid)
|
||||
user = db.data().users[user_uuid]
|
||||
user_name = user.display_name
|
||||
if name is not None:
|
||||
stripped = name.strip()
|
||||
@@ -59,7 +59,7 @@ async def websocket_register_add(
|
||||
|
||||
# Create a new session and store everything in database
|
||||
metadata = infodict(ws, "authenticated")
|
||||
token = db.create_credential_session( # type: ignore[attr-defined]
|
||||
token = db.create_credential_session(
|
||||
user_uuid=user_uuid,
|
||||
credential=credential,
|
||||
reset_key=(s.key if reset is not None else None),
|
||||
@@ -89,43 +89,20 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
session_user_uuid = None
|
||||
credential_ids = None
|
||||
if auth:
|
||||
ctx = db.data().session_ctx(auth, host)
|
||||
if ctx:
|
||||
session_user_uuid = ctx.user.uuid
|
||||
credential_ids = db.get_user_credential_ids(session_user_uuid) or None
|
||||
existing_ctx = db.data().session_ctx(auth, host)
|
||||
if existing_ctx:
|
||||
session_user_uuid = existing_ctx.user.uuid
|
||||
|
||||
cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids)
|
||||
ctx = await authenticate_and_login(ws, auth)
|
||||
|
||||
# If reauth mode, verify the credential belongs to the session's user
|
||||
if session_user_uuid and cred.user_uuid != session_user_uuid:
|
||||
if session_user_uuid and ctx.user.uuid != session_user_uuid:
|
||||
raise ValueError("This passkey belongs to a different account")
|
||||
|
||||
# Create session and update user/credential in a single transaction
|
||||
assert cred.uuid is not None
|
||||
metadata = infodict(ws, "auth")
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
hostname = normalized_host.split(":")[0]
|
||||
rp_id = passkey.instance.rp_id
|
||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
|
||||
token = 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"],
|
||||
expiry=expires(),
|
||||
)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"user": str(cred.user_uuid),
|
||||
"session_token": token,
|
||||
"user": str(ctx.user.uuid),
|
||||
"session_token": ctx.session.key,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,8 +7,12 @@ from uuid import UUID
|
||||
from fastapi import WebSocket
|
||||
|
||||
from paskia import db
|
||||
from paskia.db import Credential
|
||||
from paskia.authsession import expires
|
||||
from paskia.db import Credential, SessionContext
|
||||
from paskia.fastapi.session import infodict
|
||||
from paskia.fastapi.wsutil import validate_origin
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import hostutil
|
||||
|
||||
|
||||
async def register_chat(
|
||||
@@ -31,7 +35,6 @@ async def register_chat(
|
||||
|
||||
async def authenticate_chat(
|
||||
ws: WebSocket,
|
||||
origin: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
) -> tuple[Credential, int]:
|
||||
"""Run WebAuthn authentication flow and return the credential and new sign count.
|
||||
@@ -39,6 +42,7 @@ async def authenticate_chat(
|
||||
Returns:
|
||||
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
|
||||
"""
|
||||
origin = validate_origin(ws)
|
||||
options, challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=credential_ids
|
||||
)
|
||||
@@ -60,3 +64,52 @@ async def authenticate_chat(
|
||||
|
||||
verification = passkey.instance.auth_verify(authcred, challenge, cred, origin)
|
||||
return cred, verification.new_sign_count
|
||||
|
||||
|
||||
async def authenticate_and_login(
|
||||
ws: WebSocket,
|
||||
auth: str | None = None,
|
||||
) -> SessionContext:
|
||||
"""Run WebAuthn authentication flow, create session, and return the session context.
|
||||
|
||||
If auth is provided, restrict authentication to credentials of that session's user.
|
||||
|
||||
Returns:
|
||||
SessionContext for the authenticated session
|
||||
"""
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
hostname = normalized_host.split(":")[0]
|
||||
rp_id = passkey.instance.rp_id
|
||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
metadata = infodict(ws, "auth")
|
||||
|
||||
# Get credential IDs if restricting to a user's credentials
|
||||
credential_ids = None
|
||||
if auth:
|
||||
existing_ctx = db.data().session_ctx(auth, host)
|
||||
if existing_ctx:
|
||||
credential_ids = db.get_user_credential_ids(existing_ctx.user.uuid) or None
|
||||
|
||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||
|
||||
# Create session and update user/credential
|
||||
token = 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"],
|
||||
expiry=expires(),
|
||||
)
|
||||
|
||||
# Fetch and return the full session context
|
||||
ctx = db.data().session_ctx(token, normalized_host)
|
||||
if not ctx:
|
||||
raise ValueError("Failed to create session context")
|
||||
return ctx
|
||||
|
||||
@@ -21,12 +21,8 @@ def websocket_error_handler(func):
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(ws: WebSocket, *args, **kwargs):
|
||||
client = ws.client.host if ws.client else "-"
|
||||
host = ws.headers.get("host", "-")
|
||||
path = ws.url.path
|
||||
|
||||
start = time.perf_counter()
|
||||
ws_id = log_ws_open(client, host, path)
|
||||
ws_id = log_ws_open(ws)
|
||||
close_code = None
|
||||
|
||||
try:
|
||||
@@ -47,8 +43,7 @@ def websocket_error_handler(func):
|
||||
logging.exception("Internal Server Error")
|
||||
await ws.send_json({"status": 500, "detail": "Internal Server Error"})
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
log_ws_close(client, ws_id, close_code, duration_ms)
|
||||
log_ws_close(ws_id, close_code, time.perf_counter() - start)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
+24
-24
@@ -1,43 +1,43 @@
|
||||
import shutil
|
||||
"""Hatch build hook for building paskia-js and Vue frontend during package build."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
|
||||
# Import utilities from fastapi-vue
|
||||
exec(Path(__file__).parent.joinpath("fastapi-vue", "util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
"""Run a command and display it."""
|
||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||
stderr.write(f"### {' '.join(display_cmd)}\n")
|
||||
subprocess.run(cmd, check=True, **kwargs)
|
||||
|
||||
|
||||
def find_build_tool():
|
||||
install = [
|
||||
("deno", "install", "--allow-scripts=npm:vue-demi"),
|
||||
("npm", "install"),
|
||||
("bun", "--bun", "install"),
|
||||
]
|
||||
|
||||
build = [
|
||||
("deno", "task", "build"),
|
||||
("npm", "run", "build"),
|
||||
("bun", "--bun", "run", "build"),
|
||||
]
|
||||
|
||||
for i, b in zip(install, build, strict=False):
|
||||
if tool := shutil.which(i[0]):
|
||||
return [tool, *i[1:]], [tool, *b[1:]]
|
||||
|
||||
raise RuntimeError("Deno, npm or Bun is required for building but none was found")
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
"""Build hook that compiles paskia-js and Vue frontend before packaging."""
|
||||
|
||||
def initialize(self, version, build_data):
|
||||
super().initialize(version, build_data)
|
||||
stderr.write(">>> Building the frontend\n")
|
||||
stderr.write(">>> Building paskia-js library\n")
|
||||
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
install_cmd, build_cmd = find_build_tool() # noqa: F821 # type: ignore
|
||||
|
||||
try:
|
||||
# Install dependencies for paskia-js
|
||||
run(install_cmd, cwd="paskia-js")
|
||||
stderr.write("\n")
|
||||
# Build paskia-js
|
||||
run(build_cmd, cwd="paskia-js")
|
||||
stderr.write("\n")
|
||||
except Exception as e:
|
||||
stderr.write(f"Error occurred while building paskia-js: {e}\n")
|
||||
raise
|
||||
|
||||
stderr.write(">>> Building the frontend\n")
|
||||
|
||||
try:
|
||||
run(install_cmd, cwd="frontend")
|
||||
|
||||
Reference in New Issue
Block a user