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:
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<LoginModal />
|
||||
<SettingsModal />
|
||||
<UserManagementModal />
|
||||
<header>
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user