Consistent dialog styling widgets and using Paskia's shared backdrop. Internal password auth mimics Paskia. API paths changed (/auth goes to internal or paskia depending on config). All API calls and previews get access checks.

This commit is contained in:
Leo Vasanko
2026-01-30 18:28:26 +00:00
parent fa82fee53e
commit 14f2177514
23 changed files with 1109 additions and 386 deletions
-1
View File
@@ -1,5 +1,4 @@
<template>
<LoginModal />
<SettingsModal />
<UserManagementModal />
<header>
+2 -1
View File
@@ -4,6 +4,7 @@
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document'
import { reactive } from 'vue';
@@ -96,7 +97,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
const writable = await fileHandle.createWritable()
const url = `/files/${rel}`
console.log('Fetching', url)
const res = await fetch(url)
const res = await apiFetch(url)
if (!res.ok) {
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
+46 -5
View File
@@ -30,14 +30,27 @@
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { ref, nextTick, watchEffect } from 'vue'
import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect, computed } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS'
import router from '@/router';
const store = useMainStore()
const ssoStore = useSsoAuthStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
// Display name for SSO users
const displayUserName = computed(() => {
if (ssoStore.isExternalAuth && ssoStore.userName) {
return ssoStore.userName
}
return store.user.username
})
const props = defineProps<{
path: Array<string>
query: string
@@ -73,14 +86,42 @@ watchEffect(() => {
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
// For external auth, show user name as link to /auth/
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({
label: displayUserName.value || 'User Account',
onClick: () => { window.location.href = '/auth/' }
})
items.push({ divided: true })
}
// Only show password change for non-SSO users
if (!ssoStore.isExternalAuth) {
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
}
if (store.user.privileged) {
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
}
if (store.user.isLoggedIn) {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
} else {
items.push({ label: 'Login', onClick: () => store.loginDialog() })
if (ssoStore.isExternalAuth) {
// For SSO, link to auth logout
items.push({ label: 'Logout', onClick: () => { window.location.href = '/auth/' }})
} else {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
}
} else if (!ssoStore.isExternalAuth) {
// Show login in paskia iframe overlay
items.push({ label: 'Login', onClick: async () => {
try {
await showAuthIframe('/auth/api/restricted')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
}
}})
}
ContextMenu.showContextMenu({
// @ts-ignore
-101
View File
@@ -1,101 +0,0 @@
<template>
<ModalDialog name="login" title="Authentication required">
<form @submit.prevent="login">
<div class="login-container">
<label for="username">Username:</label>
<input
id="username"
name="username"
autocomplete="username"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.username"
/>
<label for="password">Password:</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.password"
/>
</div>
<h3 class="error-text">
{{ loginForm.error || '\u00A0' }}
</h3>
<div class="dialog-buttons">
<div class="spacer"></div>
<input id="submit" type="submit" value="Login" class="button-login" />
</div>
</form>
</ModalDialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { loginUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
const confirmLoading = ref<boolean>(false)
const store = useMainStore()
const loginForm = reactive({
username: '',
password: '',
error: ''
})
const login = async () => {
try {
loginForm.error = ''
confirmLoading.value = true
const msg = await loginUser(loginForm.username, loginForm.password)
store.login(msg.data.username, !!msg.data.privileged)
} catch (error) {
const httpError = error as ISimpleError
loginForm.error = httpError.message || '🛑 Unknown error'
} finally {
confirmLoading.value = false
}
}
</script>
<style scoped>
.login-container {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 2fr;
justify-content: center;
align-items: center;
margin: 1rem 0;
}
.dialog-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
margin-left: auto;
transition: all var(--transition-time) linear;
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
}
.error-text {
color: var(--red-color);
height: 1em;
}
</style>
+206 -31
View File
@@ -13,6 +13,7 @@
<script setup lang="ts">
import { ref, onMounted, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const dialog = ref<HTMLDialogElement | null>(null)
const store = useMainStore()
@@ -20,6 +21,7 @@ const store = useMainStore()
const close = () => {
dialog.value!.close()
store.dialog = ''
releaseGlobalBackdrop()
}
const props = defineProps<{
@@ -29,6 +31,7 @@ const props = defineProps<{
const show = () => {
store.dialog = props.name
holdGlobalBackdrop()
setTimeout(() => {
dialog.value!.showModal()
nextTick(() => {
@@ -44,47 +47,219 @@ watchEffect(() => {
</script>
<style>
/* Style for the background */
/* ===========================================
DIALOG GLOBAL STYLES
Shared styling for all modal dialogs.
Login page (auth.py) has matching CSS.
=========================================== */
dialog::backdrop {
content: '';
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0008;
backdrop-filter: blur(0.4em);
z-index: 1000;
display: none;
}
/* Hide the dialog by default */
/* Dialog container */
dialog[open] {
background: #ddd;
color: black;
display: block;
color: #000;
border: none;
font-size: 1.2rem;
border-radius: 0.5rem;
box-shadow: 0.2rem 0.2rem 1rem #000;
padding: 1rem;
box-shadow: 0 0 1rem #0008;
padding: 0;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
}
input {
font: inherit;
}
dialog[open] > h1 {
background: var(--soft-color);
color: #fff;
font-size: 1.2rem;
margin: -1rem -1rem 0 -1rem;
padding: 0.5rem 1rem 0.5rem 1rem;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1100;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
font-size: 1rem;
}
/* Dialog title bar */
dialog[open] > h1 {
background: #146;
color: #fff;
font-size: 1.2rem;
font-weight: normal;
margin: 0;
padding: 0.5rem 1rem;
position: sticky;
top: 0;
}
/* Dialog content area */
dialog[open] > div {
padding: 1em 0;
padding: 1rem;
}
/* Section headings inside dialog */
dialog h3 {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
dialog h3:first-child {
margin-top: 0;
}
/* Form inputs */
dialog input[type="text"],
dialog input[type="password"],
dialog select {
font: inherit;
font-size: 1rem;
padding: 0.5rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
min-width: 12rem;
}
dialog input[type="text"]:focus,
dialog input[type="password"]:focus,
dialog select:focus {
outline: none;
border-color: #f80;
}
/* Labels */
dialog label {
font-size: 1rem;
}
/* Buttons */
dialog button,
dialog input[type="submit"],
dialog input[type="reset"],
dialog .button {
font: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
background: #146;
color: #fff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
dialog button:hover,
dialog input[type="submit"]:hover,
dialog input[type="reset"]:hover,
dialog .button:hover {
background: #f80;
}
dialog button:disabled,
dialog input[type="submit"]:disabled,
dialog input[type="reset"]:disabled,
dialog .button:disabled {
background: #888;
cursor: not-allowed;
}
/* Small button variant */
dialog .button.small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Danger button variant */
dialog .button.danger {
background: #c00;
}
dialog .button.danger:hover:not(:disabled) {
background: #f00;
}
/* Form row layout (label + input side by side) */
dialog .form-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
margin-bottom: 0.5rem;
}
/* Form grid for multiple label+input pairs */
dialog .form-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
}
/* Dialog button row (footer) */
dialog .dialog-buttons {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
/* Error text */
dialog .error-text {
color: #c00;
font-size: 0.875rem;
min-height: 1.2em;
margin: 0.5rem 0;
}
/* Success message */
dialog .success-message {
background: #f80;
color: #000;
padding: 0.5rem;
border-radius: 0.25rem;
margin: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
/* Data tables inside dialogs */
dialog table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 1rem;
}
dialog th,
dialog td {
border: 1px solid #888;
padding: 0.5rem;
text-align: left;
}
dialog th {
background: #146;
color: #fff;
font-weight: normal;
}
dialog td {
background: #fff;
}
/* Checkbox alignment in tables */
dialog td input[type="checkbox"] {
margin: 0;
}
/* Paragraph text */
dialog p {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
/* Loading state */
dialog .loading {
padding: 2rem;
text-align: center;
color: #666;
}
</style>
+5 -36
View File
@@ -3,8 +3,8 @@
<form>
<template v-if="store.user.isLoggedIn">
<h3>Update your authentication</h3>
<div class="login-container">
<label for="username">New password:</label>
<div class="form-grid">
<label for="passwordChange">New password:</label>
<input
ref="passwordChange"
id="passwordChange"
@@ -26,9 +26,9 @@
v-model="form.password"
/>
</div>
<h3 class="error-text">
<p class="error-text">
{{ form.error || '\u00A0' }}
</h3>
</p>
<div class="dialog-buttons">
<input id="close" type="reset" value="Close" class="button" @click=close />
<div class="spacer"></div>
@@ -92,36 +92,5 @@ const submit = async (ev: Event) => {
</script>
<style scoped>
.login-container {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 2fr;
justify-content: center;
align-items: center;
margin: 1rem 0;
}
.dialog-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
margin-left: auto;
transition: all var(--transition-time) linear;
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
}
.error-text {
color: var(--red-color);
height: 1em;
}
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
</style>
+22 -60
View File
@@ -4,21 +4,25 @@
<div v-else>
<h3>Server Settings</h3>
<div class="form-row">
<input
id="publicServer"
type="checkbox"
v-model="serverSettings.public"
<label for="authMode">Authentication:</label>
<select
id="authMode"
v-model="serverSettings.authentication"
@change="updateServerSettings"
/>
<label for="publicServer">Publicly accessible without any user account.</label>
>
<option value="password">Password (built-in users)</option>
<option value="paskia">Paskia (external SSO)</option>
<option value="none">None (public access)</option>
</select>
</div>
<template v-if="serverSettings.authentication === 'password'">
<h3>Users</h3>
<button @click="addUser" class="button" title="Add new user"> Add User</button>
<div v-if="success" class="success-message" @click="copySuccess(false)">
{{ success }}
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
</div>
<table class="user-table">
<table>
<thead>
<tr>
<th>Username</th>
@@ -45,7 +49,8 @@
</tr>
</tbody>
</table>
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
</template>
<p class="error-text">{{ error || '\u00A0' }}</p>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
@@ -55,7 +60,7 @@
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import { listUsers, createUser, updateUser, deleteUser, updateAuthentication, type AuthMode } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
@@ -72,7 +77,7 @@ const error = ref('')
const success = ref('')
const copyButtonText = ref('📋')
const serverSettings = reactive({
public: false
authentication: 'password' as AuthMode
})
const close = () => {
@@ -197,9 +202,9 @@ const updateServerSettings = async () => {
try {
error.value = ''
success.value = ''
await updatePublic(serverSettings.public)
await updateAuthentication(serverSettings.authentication)
// Update store
store.server.public = serverSettings.public
store.server.authentication = serverSettings.authentication
success.value = 'Server settings updated'
} catch (e) {
const httpError = e as ISimpleError
@@ -208,58 +213,15 @@ const updateServerSettings = async () => {
}
onMounted(() => {
serverSettings.public = store.server.public
serverSettings.authentication = store.server.authentication || 'password'
loadUsers()
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal
watch(() => store.server.authentication, (newVal) => {
serverSettings.authentication = newVal || 'password'
})
</script>
<style scoped>
.user-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.user-table th, .user-table td {
border: 1px solid var(--border-color);
padding: 0.5rem;
text-align: left;
}
.user-table th {
background: var(--soft-color);
}
.button.small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
margin-right: 0.25rem;
}
.button.danger {
background: var(--red-color);
color: white;
}
.button.danger:hover {
background: #d00;
}
.form-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.form-row label {
min-width: 100px;
}
.success-message {
background: var(--accent-color);
color: white;
padding: 0.5rem;
border-radius: 0.25rem;
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
</style>
+49 -48
View File
@@ -1,71 +1,71 @@
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
// Type for API error responses
interface ApiError {
error: {
code: number
message: string
}
}
function hasError(msg: unknown): msg is ApiError {
return typeof msg === 'object' && msg !== null && 'error' in msg
}
class ClientClass {
async get(url: string): Promise<any> {
const res = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json'
}
})
let msg
try {
msg = await res.json()
const msg = await apiJson(url, { method: 'GET' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
async post(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
try {
msg = await res.json()
const msg = await apiJson(url, {
method: 'POST',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
async put(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
try {
msg = await res.json()
const msg = await apiJson(url, {
method: 'PUT',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
async delete(url: string): Promise<any> {
const res = await fetch(url, {
method: 'DELETE',
headers: {
accept: 'application/json'
}
})
let msg
try {
msg = await res.json()
const msg = await apiJson(url, { method: 'DELETE' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
}
@@ -82,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
}
}
export { apiFetch }
export default Client
+8 -6
View File
@@ -1,8 +1,8 @@
import Client from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
export const url_login = '/login'
export const url_logout = '/logout'
export const url_password = '/password-change'
export const url_login = '/auth/login'
export const url_logout = '/auth/logout'
export const url_password = '/auth/password-change'
export async function loginUser(username: string, password: string) {
const user = await Client.post(url_login, {
@@ -25,7 +25,7 @@ export async function changePassword(username: string, passwordChange: string, p
return data
}
export const url_users = '/users'
export const url_users = '/auth/users'
export async function listUsers() {
const data = await Client.get(url_users)
@@ -51,7 +51,9 @@ export async function deleteUser(username: string) {
return data
}
export async function updatePublic(publicFlag: boolean) {
const data = await Client.put('/config/public', { public: publicFlag })
export type AuthMode = 'none' | 'paskia' | 'password'
export async function updateAuthentication(mode: AuthMode) {
const data = await Client.put('/api/config/authentication', { authentication: mode })
return data
}
+59 -12
View File
@@ -1,4 +1,6 @@
import { useMainStore } from "@/stores/main"
import { useSsoAuthStore } from "@/stores/ssoAuth"
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
export const controlUrl = '/api/control'
@@ -8,6 +10,8 @@ export const watchUrl = '/api/watch'
let tree = [] as FileEntry[]
let reconnDelay = 500
let wsWatch = null as WebSocket | null
// Track when we're awaiting authentication to prevent reconnection loops
let awaitingAuth = false
export const loadSession = () => {
const s = localStorage['cista-files']
@@ -34,6 +38,35 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
return webSocket
}
// Handle auth error from WebSocket - show paskia iframe and reconnect on success
async function handleWsAuthError(msg: any) {
const iframe = msg.error?.auth?.iframe
if (iframe) {
// Stop reconnection attempts while showing auth dialog
awaitingAuth = true
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
}
try {
await showAuthIframe(iframe)
// Auth succeeded - reconnect
awaitingAuth = false
watchConnect()
} catch (e) {
awaitingAuth = false
if (e instanceof AuthCancelledError) {
console.log('User cancelled authentication')
// User cancelled - don't automatically retry, wait for user action
} else {
console.error('Auth iframe error:', e)
}
}
return true
}
return false
}
export const watchConnect = () => {
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
@@ -51,9 +84,9 @@ export const watchConnect = () => {
if (store.connected) return
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.dialog = 'login'
if (msg.error.code === 401 || msg.error.code === 403) {
// Show paskia auth iframe (works for both password and paskia modes)
handleWsAuthError(msg)
} else {
store.error = msg.error.message
}
@@ -67,7 +100,11 @@ export const watchConnect = () => {
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
if (!msg.server.public && !msg.user) store.dialog = 'login'
// Start SSO validation polling only in paskia mode
if (msg.server.authentication === 'paskia') {
const ssoStore = useSsoAuthStore()
ssoStore.startValidationPolling()
}
}
})
}
@@ -78,21 +115,31 @@ export const watchDisconnect = () => {
wsWatch = null
}
// Reset auth state and reconnect - call after successful authentication
export const resumeWatching = () => {
awaitingAuth = false
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
}
watchConnect()
}
let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => {
const store = useMainStore()
// Don't reconnect if we're awaiting authentication or auth iframe is showing
if (awaitingAuth || isAuthIframeOpen()) {
console.log('Skipping reconnect - awaiting authentication')
return
}
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
store.error = 'Reconnecting...'
}
if (watchTimeout !== null) clearTimeout(watchTimeout)
// Don't hammer the server while on login dialog
if (store.dialog === 'login') {
watchTimeout = setTimeout(watchReconnect, 100)
return
}
reconnDelay = Math.min(5000, reconnDelay + 500)
// The server closes the websocket after errors, so we need to reopen it
watchTimeout = setTimeout(watchConnect, reconnDelay)
@@ -152,9 +199,9 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
function handleError(msg: errorEvent) {
const store = useMainStore()
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.dialog = 'login'
if (msg.error.code === 401 || msg.error.code === 403) {
// Show paskia auth iframe (works for both password and paskia modes)
handleWsAuthError(msg as any)
return
}
}
+4 -7
View File
@@ -3,7 +3,7 @@ import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
import { watchConnect, resumeWatching } from '@/repositories/WS'
import { shallowRef } from 'vue'
import { sorted, type SortOrder } from '@/utils/docsort'
@@ -17,8 +17,8 @@ export const useMainStore = defineStore({
error: '' as string,
connected: false,
cursor: '' as string,
server: {} as Record<string, any>,
dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
server: {} as Record<string, any> & { authentication?: 'none' | 'paskia' | 'password' },
dialog: '' as '' | 'settings' | 'usermgmt',
uprogress: {} as any,
dprogress: {} as any,
prefs: {
@@ -69,10 +69,7 @@ export const useMainStore = defineStore({
this.user.privileged = privileged
this.user.isLoggedIn = true
this.dialog = ''
if (!this.connected) watchConnect()
},
loginDialog() {
this.dialog = 'login'
if (!this.connected) resumeWatching()
},
async logout() {
console.log("Logout")
+107
View File
@@ -0,0 +1,107 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useMainStore } from './main'
import { SessionValidator, apiFetch, AuthCancelledError } from 'paskia'
// Session validator instance (only used in paskia mode)
let sessionValidator: SessionValidator | null = null
export const useSsoAuthStore = defineStore('ssoAuth', () => {
// State
const userName = ref('')
const userUuid = ref('')
// Getters
const isExternalAuth = computed(() => {
const mainStore = useMainStore()
return mainStore.server?.authentication === 'paskia'
})
// Actions
function clearDataOnUnauth() {
const mainStore = useMainStore()
// Clear localStorage
localStorage.removeItem('cista-files')
// Clear visible files by resetting document
mainStore.document = []
mainStore.selected.clear()
mainStore.user.isLoggedIn = false
userName.value = ''
userUuid.value = ''
}
function handleSessionLost(error: Error) {
console.warn('Session lost:', error)
clearDataOnUnauth()
// Trigger re-authentication by reloading - paskia will handle the auth flow
location.reload()
}
async function validateSession(): Promise<boolean> {
// Only do session validation in paskia mode
if (!isExternalAuth.value) return true
try {
const res = await apiFetch('/auth/api/validate', {
method: 'POST',
headers: { 'accept': 'application/json' }
})
if (res.ok) {
// Extract user display name from Remote-Name header
userName.value = res.headers.get('Remote-Name') || ''
try {
const data = await res.json()
if (data.uuid) userUuid.value = data.uuid
} catch {
// Response may not have JSON body
}
return true
}
return false
} catch (e) {
if (e instanceof AuthCancelledError) {
console.log('User cancelled authentication')
return false
}
console.error('SSO validation error:', e)
return false
}
}
function startValidationPolling() {
if (!isExternalAuth.value) return
// Stop any existing validator
stopValidationPolling()
// Initial validation to get user info
validateSession()
// Use paskia's SessionValidator for ongoing session monitoring
sessionValidator = new SessionValidator(
() => userUuid.value || undefined, // getter for current user ID
handleSessionLost // callback when session is lost
)
sessionValidator.start()
}
function stopValidationPolling() {
if (sessionValidator) {
sessionValidator.stop()
sessionValidator = null
}
}
return {
// State
userName,
userUuid,
// Getters
isExternalAuth,
// Actions
validateSession,
clearDataOnUnauth,
startValidationPolling,
stopValidationPolling,
}
})