Finalize Paskia integration and built-in authentication.

This commit is contained in:
Leo Vasanko
2026-01-31 00:48:02 +00:00
parent 1dab7f0fd1
commit 9c173ff0bf
19 changed files with 668 additions and 449 deletions
+25
View File
@@ -1,6 +1,10 @@
<template>
<div v-if="store.error && !store.authInProgress" class="toast-message" @click="store.error = ''">
{{ store.error }}
</div>
<SettingsModal />
<UserManagementModal />
<AccessDeniedModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" />
@@ -29,6 +33,7 @@ import Router from '@/router/index'
import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
interface Path {
path: string
@@ -157,3 +162,23 @@ onUnmounted(() => {
})
export type { Path }
</script>
<style>
/* Toast notifications - fixed at top center of viewport */
.toast-message {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
padding: 0.75rem 1.5rem;
background: var(--accent-color);
color: #000;
font-weight: bold;
border-radius: 0.25rem;
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
cursor: pointer;
max-width: 90vw;
text-align: center;
}
</style>
+137
View File
@@ -0,0 +1,137 @@
// SVG icon index - all icons bundled together
import AddFile from './add-file.svg'
import AddFolder from './add-folder.svg'
import Arrow from './arrow.svg'
import ArrowsH from './arrows-h.svg'
import ArrowsV from './arrows-v.svg'
import Check from './check.svg'
import Code from './code.svg'
import Cog from './cog.svg'
import Copy from './copy.svg'
import CreateFile from './create-file.svg'
import CreateFolder from './create-folder.svg'
import Cross from './cross.svg'
import Disk from './disk.svg'
import Download from './download.svg'
import Exclamation from './exclamation.svg'
import Eye from './eye.svg'
import Find from './find.svg'
import Fullscreen from './fullscreen.svg'
import Github from './github.svg'
import Home from './home.svg'
import Info from './info.svg'
import Link from './link.svg'
import Logo from './logo.svg'
import Loop from './loop.svg'
import Menu from './menu.svg'
import Next from './next.svg'
import Open from './open.svg'
import Paste from './paste.svg'
import Pause from './pause.svg'
import Pencil from './pencil.svg'
import Play from './play.svg'
import Plus from './plus.svg'
import Previous from './previous.svg'
import Reload from './reload.svg'
import Rename from './rename.svg'
import Scissors from './scissors.svg'
import Shuffle from './shuffle.svg'
import Signin from './signin.svg'
import Signout from './signout.svg'
import Skip from './skip.svg'
import Spinner from './spinner.svg'
import Stop from './stop.svg'
import Trash from './trash.svg'
import Triangle from './triangle.svg'
import Unfullscreen from './unfullscreen.svg'
import UpArrow from './up-arrow.svg'
import UploadCloud from './upload-cloud.svg'
import UserCog from './user-cog.svg'
import User from './user.svg'
import VolumeHigh from './volume-high.svg'
import VolumeLow from './volume-low.svg'
import VolumeMedium from './volume-medium.svg'
import VolumeMute from './volume-mute.svg'
import WindowCross from './window-cross.svg'
import Window from './window.svg'
import Wordwrap from './wordwrap.svg'
import Zoomin from './zoomin.svg'
import Zoomout from './zoomout.svg'
// Named exports for direct imports
export {
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
Disk, Download, Exclamation, Eye, Find, Fullscreen,
Github, Home, Info, Link, Logo, Loop, Menu,
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
Zoomin, Zoomout
}
// Icon lookup by kebab-case name (for SvgButton compatibility)
export const icons = {
'add-file': AddFile,
'add-folder': AddFolder,
'arrow': Arrow,
'arrows-h': ArrowsH,
'arrows-v': ArrowsV,
'check': Check,
'code': Code,
'cog': Cog,
'copy': Copy,
'create-file': CreateFile,
'create-folder': CreateFolder,
'cross': Cross,
'disk': Disk,
'download': Download,
'exclamation': Exclamation,
'eye': Eye,
'find': Find,
'fullscreen': Fullscreen,
'github': Github,
'home': Home,
'info': Info,
'link': Link,
'logo': Logo,
'loop': Loop,
'menu': Menu,
'next': Next,
'open': Open,
'paste': Paste,
'pause': Pause,
'pencil': Pencil,
'play': Play,
'plus': Plus,
'previous': Previous,
'reload': Reload,
'rename': Rename,
'scissors': Scissors,
'shuffle': Shuffle,
'signin': Signin,
'signout': Signout,
'skip': Skip,
'spinner': Spinner,
'stop': Stop,
'trash': Trash,
'triangle': Triangle,
'unfullscreen': Unfullscreen,
'up-arrow': UpArrow,
'upload-cloud': UploadCloud,
'user-cog': UserCog,
'user': User,
'volume-high': VolumeHigh,
'volume-low': VolumeLow,
'volume-medium': VolumeMedium,
'volume-mute': VolumeMute,
'window-cross': WindowCross,
'window': Window,
'wordwrap': Wordwrap,
'zoomin': Zoomin,
'zoomout': Zoomout,
} as const
export type IconName = keyof typeof icons
@@ -0,0 +1,46 @@
<template>
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
<div class="modal-dialog" id="accessdenied">
<div class="modal-content access-denied">
<p class="icon"></p>
<p class="message">Access Denied</p>
<button @click="reload" class="button">Reload</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop } from 'paskia'
import { watchEffect } from 'vue'
const store = useMainStore()
const reload = () => {
location.reload()
}
// Keep backdrop active when this dialog shows
watchEffect(() => {
if (store.dialog === 'accessdenied') {
holdGlobalBackdrop()
}
})
</script>
<style scoped>
.access-denied {
text-align: center;
padding: 2rem !important;
}
.access-denied .icon {
font-size: 4rem;
margin: 0 0 1rem 0;
}
.access-denied .message {
font-size: 1.5rem;
font-weight: bold;
margin: 0 0 1.5rem 0;
}
</style>
+6 -2
View File
@@ -1,7 +1,8 @@
<template>
<div v-if="!props.path || documents.length === 0" class="empty-container">
<component :is="cog" class="cog"/>
<p v-if="!store.connected">No Connection</p>
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p>
<p v-else-if="store.document.length === 0">Waiting for File List</p>
<p v-else-if="store.query">No matches!</p>
<p v-else-if="!exists(props.path)">Folder not found</p>
@@ -35,4 +36,7 @@ svg.cog {
filter: drop-shadow(0 0 1rem black);
fill: #888;
}
svg.cog.stopped {
animation: none;
}
</style>
+8 -26
View File
@@ -1,9 +1,5 @@
<template>
<nav class="headermain buttons">
<template v-if="store.error">
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
@@ -31,7 +27,7 @@
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect, computed } from 'vue'
import { ref, nextTick, watchEffect } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS'
@@ -43,14 +39,6 @@ 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
@@ -90,33 +78,27 @@ const settingsMenu = (e: Event) => {
// For external auth, show user name as link to /auth/
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({
label: displayUserName.value || 'User Account',
label: '👤 ' + (store.user.username || '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 (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
}
if (store.user.privileged) {
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
}
if (store.user.isLoggedIn) {
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() })
}
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else if (!ssoStore.isExternalAuth) {
// Show login in paskia iframe overlay
items.push({ label: 'Login', onClick: async () => {
items.push({ label: '🔐 Login', onClick: async () => {
try {
await showAuthIframe('/auth/api/restricted')
await showAuthIframe('/auth/restricted')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
+81 -67
View File
@@ -1,25 +1,27 @@
<template>
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
<h1 v-if="props.title">{{ props.title }}</h1>
<div>
<slot>
Dialog with no content
<button @click=close>OK</button>
</slot>
<div v-if="store.dialog === name" class="modal-overlay" @click.self="close" @keydown.escape="close" tabindex="-1" ref="overlay">
<div class="modal-dialog" :id="props.name" ref="dialog">
<h1 v-if="props.title">{{ props.title }}</h1>
<div class="modal-content">
<slot>
Dialog with no content
<button @click="close">OK</button>
</slot>
</div>
</div>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watchEffect, nextTick } from 'vue'
import { ref, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const dialog = ref<HTMLDialogElement | null>(null)
const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null)
const store = useMainStore()
const close = () => {
dialog.value!.close()
store.dialog = ''
releaseGlobalBackdrop()
}
@@ -32,44 +34,48 @@ const props = defineProps<{
const show = () => {
store.dialog = props.name
holdGlobalBackdrop()
setTimeout(() => {
dialog.value!.showModal()
nextTick(() => {
const input = dialog.value!.querySelector('input')
if (input) input.focus()
})
}, 0)
nextTick(() => {
overlay.value?.focus()
const input = dialog.value?.querySelector('input')
if (input) input.focus()
})
}
defineExpose({ show, close })
watchEffect(() => {
if (dialog.value) show()
if (overlay.value) {
overlay.value.focus()
const input = dialog.value?.querySelector('input')
if (input) input.focus()
}
})
</script>
<style>
/* ===========================================
DIALOG GLOBAL STYLES
MODAL DIALOG GLOBAL STYLES
Shared styling for all modal dialogs.
Login page (auth.py) has matching CSS.
=========================================== */
dialog::backdrop {
display: none;
/* Overlay - covers entire viewport */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
/* No backdrop - paskia handles that */
}
/* Dialog container */
dialog[open] {
.modal-dialog {
background: #ddd;
color: #000;
border: none;
border-radius: 0.5rem;
box-shadow: 0 0 1rem #0008;
padding: 0;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1100;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
@@ -77,7 +83,7 @@ dialog[open] {
}
/* Dialog title bar */
dialog[open] > h1 {
.modal-dialog > h1 {
background: #146;
color: #fff;
font-size: 1.2rem;
@@ -89,24 +95,32 @@ dialog[open] > h1 {
}
/* Dialog content area */
dialog[open] > div {
.modal-dialog > .modal-content {
padding: 1rem;
}
/* Section headings inside dialog */
dialog h3 {
.modal-dialog h3 {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
dialog h3:first-child {
.modal-dialog h3:first-child {
margin-top: 0;
}
/* Links */
.modal-dialog a {
color: #146;
}
.modal-dialog a:hover {
color: #f80;
}
/* Form inputs */
dialog input[type="text"],
dialog input[type="password"],
dialog select {
.modal-dialog input[type="text"],
.modal-dialog input[type="password"],
.modal-dialog select {
font: inherit;
font-size: 1rem;
padding: 0.5rem;
@@ -117,23 +131,23 @@ dialog select {
min-width: 12rem;
}
dialog input[type="text"]:focus,
dialog input[type="password"]:focus,
dialog select:focus {
.modal-dialog input[type="text"]:focus,
.modal-dialog input[type="password"]:focus,
.modal-dialog select:focus {
outline: none;
border-color: #f80;
}
/* Labels */
dialog label {
.modal-dialog label {
font-size: 1rem;
}
/* Buttons */
dialog button,
dialog input[type="submit"],
dialog input[type="reset"],
dialog .button {
.modal-dialog button,
.modal-dialog input[type="submit"],
.modal-dialog input[type="reset"],
.modal-dialog .button {
font: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
@@ -144,37 +158,37 @@ dialog .button {
cursor: pointer;
}
dialog button:hover,
dialog input[type="submit"]:hover,
dialog input[type="reset"]:hover,
dialog .button:hover {
.modal-dialog button:hover,
.modal-dialog input[type="submit"]:hover,
.modal-dialog input[type="reset"]:hover,
.modal-dialog .button:hover {
background: #f80;
}
dialog button:disabled,
dialog input[type="submit"]:disabled,
dialog input[type="reset"]:disabled,
dialog .button:disabled {
.modal-dialog button:disabled,
.modal-dialog input[type="submit"]:disabled,
.modal-dialog input[type="reset"]:disabled,
.modal-dialog .button:disabled {
background: #888;
cursor: not-allowed;
}
/* Small button variant */
dialog .button.small {
.modal-dialog .button.small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Danger button variant */
dialog .button.danger {
.modal-dialog .button.danger {
background: #c00;
}
dialog .button.danger:hover:not(:disabled) {
.modal-dialog .button.danger:hover:not(:disabled) {
background: #f00;
}
/* Form row layout (label + input side by side) */
dialog .form-row {
.modal-dialog .form-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
@@ -183,7 +197,7 @@ dialog .form-row {
}
/* Form grid for multiple label+input pairs */
dialog .form-grid {
.modal-dialog .form-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
@@ -191,7 +205,7 @@ dialog .form-grid {
}
/* Dialog button row (footer) */
dialog .dialog-buttons {
.modal-dialog .dialog-buttons {
display: flex;
justify-content: flex-end;
align-items: center;
@@ -200,7 +214,7 @@ dialog .dialog-buttons {
}
/* Error text */
dialog .error-text {
.modal-dialog .error-text {
color: #c00;
font-size: 0.875rem;
min-height: 1.2em;
@@ -208,7 +222,7 @@ dialog .error-text {
}
/* Success message */
dialog .success-message {
.modal-dialog .success-message {
background: #f80;
color: #000;
padding: 0.5rem;
@@ -221,43 +235,43 @@ dialog .success-message {
}
/* Data tables inside dialogs */
dialog table {
.modal-dialog table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 1rem;
}
dialog th,
dialog td {
.modal-dialog th,
.modal-dialog td {
border: 1px solid #888;
padding: 0.5rem;
text-align: left;
}
dialog th {
.modal-dialog th {
background: #146;
color: #fff;
font-weight: normal;
}
dialog td {
.modal-dialog td {
background: #fff;
}
/* Checkbox alignment in tables */
dialog td input[type="checkbox"] {
.modal-dialog td input[type="checkbox"] {
margin: 0;
}
/* Paragraph text */
dialog p {
.modal-dialog p {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
/* Loading state */
dialog .loading {
.modal-dialog .loading {
padding: 2rem;
text-align: center;
color: #666;
+4 -9
View File
@@ -26,9 +26,6 @@
v-model="form.password"
/>
</div>
<p class="error-text">
{{ form.error || '\u00A0' }}
</p>
<div class="dialog-buttons">
<input id="close" type="reset" value="Close" class="button" @click=close />
<div class="spacer"></div>
@@ -54,28 +51,26 @@ import { useMainStore } from '@/stores/main'
const confirmLoading = ref<boolean>(false)
const store = useMainStore()
const passwordChange = ref()
const password = ref()
const form = reactive({
passwordChange: '',
password: '',
error: ''
password: ''
})
const close = () => {
form.passwordChange = ''
form.password = ''
form.error = ''
store.dialog = ''
}
const submit = async (ev: Event) => {
ev.preventDefault()
try {
form.error = ''
if (form.passwordChange) {
if (!form.password) {
form.error = '⚠️ Current password is required'
store.error = '⚠️ Current password is required'
password.value!.focus()
return
}
@@ -84,7 +79,7 @@ const submit = async (ev: Event) => {
close()
} catch (error) {
const httpError = error as ISimpleError
form.error = httpError.message || '🛑 Unknown error'
store.error = httpError.message || '🛑 Unknown error'
} finally {
confirmLoading.value = false
}
+69 -68
View File
@@ -4,53 +4,55 @@
<div v-else>
<h3>Server Settings</h3>
<div class="form-row">
<label for="authMode">Authentication:</label>
<select
id="authMode"
v-model="serverSettings.authentication"
@change="updateServerSettings"
>
<option value="password">Password (built-in users)</option>
<option value="paskia">Paskia (external SSO)</option>
<option value="none">None (public access)</option>
</select>
<label for="publicAccess">
<input
type="checkbox"
id="publicAccess"
v-model="serverSettings.public"
@change="updateServerSettings"
/>
Public access (anyone can read and write)
</label>
</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>
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.username">
<td>{{ user.username }}</td>
<td>
<input
type="checkbox"
:checked="user.privileged"
@change="toggleAdmin(user, $event)"
:disabled="user.username === store.user.username"
/>
</td>
<td>
<button @click="renameUser(user)" class="button small" title="Rename user"></button>
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑</button>
</td>
</tr>
</tbody>
</table>
<template v-if="store.server.paskia">
<h3>User Management</h3>
<p>See <a href="/auth/admin/">Paskia Admin</a>.</p>
</template>
<template v-else>
<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>
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.username">
<td>{{ user.username }}</td>
<td>
<input
type="checkbox"
:checked="user.privileged"
@change="toggleAdmin(user, $event)"
:disabled="user.username === store.user.username"
/>
</td>
<td>
<button @click="renameUser(user)" class="button small" title="Rename user"></button>
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑</button>
</td>
</tr>
</tbody>
</table>
</template>
<p class="error-text">{{ error || '\u00A0' }}</p>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
@@ -60,7 +62,7 @@
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updateAuthentication, type AuthMode } from '@/repositories/User'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
@@ -73,16 +75,14 @@ interface User {
const store = useMainStore()
const loading = ref(true)
const users = ref<User[]>([])
const error = ref('')
const success = ref('')
const copyButtonText = ref('📋')
const serverSettings = reactive({
authentication: 'password' as AuthMode
public: false
})
const close = () => {
store.dialog = ''
error.value = ''
success.value = ''
}
@@ -93,7 +93,7 @@ const loadUsers = async () => {
users.value = data.users
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to load users'
store.error = httpError.message || 'Failed to load users'
} finally {
loading.value = false
}
@@ -103,7 +103,6 @@ const addUser = async () => {
const username = window.prompt('Enter username for new user:')
if (!username || !username.trim()) return
try {
error.value = ''
success.value = ''
const result = await createUser(username.trim(), undefined, false)
await loadUsers()
@@ -112,19 +111,18 @@ const addUser = async () => {
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to add user'
store.error = httpError.message || 'Failed to add user'
}
}
const toggleAdmin = async (user: User, event: Event) => {
const target = event.target as HTMLInputElement
try {
error.value = ''
await updateUser(user.username, { privileged: target.checked })
user.privileged = target.checked
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to update user'
store.error = httpError.message || 'Failed to update user'
target.checked = user.privileged // revert
}
}
@@ -135,7 +133,6 @@ const renameUser = async (user: User) => {
// For rename, we need to create new user and delete old, or have a rename endpoint
// Since no rename endpoint, perhaps delete and create
try {
error.value = ''
success.value = ''
const result = await createUser(newName.trim(), undefined, user.privileged)
await deleteUser(user.username)
@@ -145,14 +142,13 @@ const renameUser = async (user: User) => {
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to rename user'
store.error = httpError.message || 'Failed to rename user'
}
}
const resetPassword = async (user: User) => {
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
try {
error.value = ''
success.value = ''
const result = await updateUser(user.username, { password: "" })
if (result.password) {
@@ -160,19 +156,18 @@ const resetPassword = async (user: User) => {
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to reset password'
store.error = httpError.message || 'Failed to reset password'
}
}
const deleteUserAction = async (username: string) => {
if (!confirm(`Delete user ${username}?`)) return
try {
error.value = ''
await deleteUser(username)
await loadUsers()
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to delete user'
store.error = httpError.message || 'Failed to delete user'
}
}
@@ -200,25 +195,31 @@ const copySuccess = async (isButtonClick: boolean = false) => {
const updateServerSettings = async () => {
try {
error.value = ''
success.value = ''
await updateAuthentication(serverSettings.authentication)
await updatePublic(serverSettings.public)
// Update store
store.server.authentication = serverSettings.authentication
store.server.public = serverSettings.public
success.value = 'Server settings updated'
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to update settings'
store.error = httpError.message || 'Failed to update settings'
}
}
onMounted(() => {
serverSettings.authentication = store.server.authentication || 'password'
loadUsers()
serverSettings.public = store.server.public || false
loading.value = false
})
watch(() => store.server.authentication, (newVal) => {
serverSettings.authentication = newVal || 'password'
// Load users when dialog opens (only in built-in auth mode)
watch(() => store.dialog, (newVal) => {
if (newVal === 'usermgmt' && !store.server.paskia) {
loadUsers()
}
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal || false
})
</script>
+3 -5
View File
@@ -1,7 +1,7 @@
import Client from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
export const url_login = '/auth/login'
export const url_logout = '/auth/logout'
export const url_logout = '/auth/api/logout'
export const url_password = '/auth/password-change'
export async function loginUser(username: string, password: string) {
@@ -51,9 +51,7 @@ export async function deleteUser(username: string) {
return data
}
export type AuthMode = 'none' | 'paskia' | 'password'
export async function updateAuthentication(mode: AuthMode) {
const data = await Client.put('/api/config/authentication', { authentication: mode })
export async function updatePublic(isPublic: boolean) {
const data = await Client.put('/api/config/public', { public: isPublic })
return data
}
+15 -7
View File
@@ -1,5 +1,4 @@
import { useMainStore } from "@/stores/main"
import { useSsoAuthStore } from "@/stores/ssoAuth"
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
@@ -13,6 +12,11 @@ let wsWatch = null as WebSocket | null
// Track when we're awaiting authentication to prevent reconnection loops
let awaitingAuth = false
// Clear the local tree cache (called on logout/auth failure)
export const clearTree = () => {
tree = []
}
export const loadSession = () => {
const s = localStorage['cista-files']
if (!s) return false
@@ -42,8 +46,14 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
async function handleWsAuthError(msg: any) {
const iframe = msg.error?.auth?.iframe
if (iframe) {
// Clear sensitive data immediately on auth failure
const store = useMainStore()
store.clearSensitiveData()
clearTree()
// Stop reconnection attempts while showing auth dialog
awaitingAuth = true
store.authInProgress = true
store.error = '' // Clear any connection message
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
@@ -52,12 +62,15 @@ async function handleWsAuthError(msg: any) {
await showAuthIframe(iframe)
// Auth succeeded - reconnect
awaitingAuth = false
store.authInProgress = false
watchConnect()
} catch (e) {
awaitingAuth = false
store.authInProgress = false
if (e instanceof AuthCancelledError) {
console.log('User cancelled authentication')
// User cancelled - don't automatically retry, wait for user action
// Show access denied dialog
store.dialog = 'accessdenied'
} else {
console.error('Auth iframe error:', e)
}
@@ -100,11 +113,6 @@ export const watchConnect = () => {
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
// Start SSO validation polling only in paskia mode
if (msg.server.authentication === 'paskia') {
const ssoStore = useSsoAuthStore()
ssoStore.startValidationPolling()
}
}
})
}
+28 -7
View File
@@ -2,7 +2,6 @@ import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect, resumeWatching } from '@/repositories/WS'
import { sorted, type SortOrder } from '@/utils/docsort'
@@ -14,9 +13,10 @@ export const useMainStore = defineStore('main', {
fileExplorer: null as any,
error: '' as string,
connected: false,
authInProgress: false,
cursor: '' as string,
server: {} as Record<string, any> & { authentication?: 'none' | 'paskia' | 'password' },
dialog: '' as '' | 'settings' | 'usermgmt',
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
uprogress: {} as any,
dprogress: {} as any,
prefs: {
@@ -69,12 +69,33 @@ export const useMainStore = defineStore('main', {
this.dialog = ''
if (!this.connected) resumeWatching()
},
clearSensitiveData() {
// Clear all sensitive state on logout or auth failure
localStorage.removeItem('cista-files')
this.document = []
this.selected.clear()
this.user.username = ''
this.user.privileged = false
this.user.isLoggedIn = false
this.connected = false
this.dialog = ''
this.cursor = ''
},
async logout() {
console.log("Logout")
await logoutUser()
this.$reset()
localStorage.clear()
history.go() // Reload page
try {
const res = await fetch('/auth/api/logout', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
this.error = data.message || data.detail || 'Logout failed'
return
}
} catch (e) {
this.error = 'Logout failed'
return
}
this.clearSensitiveData()
resumeWatching()
},
toggleSort(name: SortOrder) {
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
+6 -94
View File
@@ -1,107 +1,19 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { 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
import { clearTree } from '@/repositories/WS'
export const useSsoAuthStore = defineStore('ssoAuth', () => {
// State
const userName = ref('')
const userUuid = ref('')
// Getters
const isExternalAuth = computed(() => {
const mainStore = useMainStore()
return mainStore.server?.authentication === 'paskia'
return mainStore.server?.paskia === true
})
// 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 = ''
mainStore.clearSensitiveData()
clearTree()
}
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,
}
return { isExternalAuth, clearDataOnUnauth }
})