WebDAV sync support, access tokens, REST control endpoints (#10)
Implement complete WebDAV file serving compatible with various clients from Windows File Explorer to more specialized sync tools. The old control WebSocket has been updated to part-DAV, part REST API instead. Implemented user:pass BASIC auth. Added UI and backend for creating tokens that avoid the need to use actual username and password for requests from CLI or DAV.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
</div>
|
||||
<SettingsModal />
|
||||
<UserManagementModal />
|
||||
<UserTokensModal />
|
||||
<AccessDeniedModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||
@@ -35,6 +36,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 UserTokensModal from './components/UserTokensModal.vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTic
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { formatSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
@@ -87,31 +87,36 @@ const props = defineProps<{
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
const router = useRouter()
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
const data = await res.json()
|
||||
return data.message || data.detail || `${res.status} ${res.statusText}`
|
||||
} catch {
|
||||
return `${res.status} ${res.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
// File rename
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const rename = (doc: Doc, newName: string) => {
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if ('error' in msg) {
|
||||
console.error('Rename failed', msg.error.message, msg.error)
|
||||
doc.name = oldName
|
||||
} else {
|
||||
console.log('Rename succeeded', msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
op: 'rename',
|
||||
path: `${doc.loc}/${oldName}`,
|
||||
to: newName
|
||||
})
|
||||
)
|
||||
}
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
try {
|
||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||
const res = await apiFetch(
|
||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} catch (err) {
|
||||
console.error('Rename failed', err)
|
||||
doc.name = oldName
|
||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||
}
|
||||
}
|
||||
defineExpose({
|
||||
newFolder() {
|
||||
@@ -253,31 +258,20 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
||||
const mkdir = (doc: Doc, name: string) => {
|
||||
const control = connect(controlUrl, {
|
||||
open() {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
op: 'mkdir',
|
||||
path: `${doc.loc}/${name}`
|
||||
})
|
||||
)
|
||||
},
|
||||
message(ev: MessageEvent) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if ('error' in msg) {
|
||||
console.error('Mkdir failed', msg.error.message, msg.error)
|
||||
editing.value = null
|
||||
} else {
|
||||
console.log('mkdir', msg)
|
||||
router.push(doc.urlrouter)
|
||||
}
|
||||
}
|
||||
})
|
||||
const mkdir = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||
try {
|
||||
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
router.push(doc.urlrouter)
|
||||
} catch (err) {
|
||||
console.error('Mkdir failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
||||
}
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
@@ -373,24 +367,17 @@ const copyImage = async (doc: Doc) => {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = (doc: Doc) => {
|
||||
const deleteFile = async (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
store.hideDoc(path)
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Delete failed', res.error)
|
||||
store.unhideDoc(path)
|
||||
store.showToast(res.error.message || 'Delete failed')
|
||||
} else if (res.status === 'ack') {
|
||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
|
||||
try {
|
||||
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||
} catch (err) {
|
||||
console.error('Delete failed', err)
|
||||
store.unhideDoc(path)
|
||||
store.showToast(err instanceof Error ? err.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { connect, controlUrl } from '@/repositories/WS'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import type { SortOrder } from '@/utils/docsort'
|
||||
@@ -23,32 +23,37 @@ const props = defineProps<{
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
const router = useRouter()
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
const data = await res.json()
|
||||
return data.message || data.detail || `${res.status} ${res.statusText}`
|
||||
} catch {
|
||||
return `${res.status} ${res.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
// File rename
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const exit = () => { editing.value = null }
|
||||
const rename = (doc: Doc, newName: string) => {
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if ('error' in msg) {
|
||||
console.error('Rename failed', msg.error.message, msg.error)
|
||||
doc.name = oldName
|
||||
} else {
|
||||
console.log('Rename succeeded', msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
op: 'rename',
|
||||
path: `${doc.loc}/${oldName}`,
|
||||
to: newName
|
||||
})
|
||||
)
|
||||
}
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
try {
|
||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||
const res = await apiFetch(
|
||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} catch (err) {
|
||||
console.error('Rename failed', err)
|
||||
doc.name = oldName
|
||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||
}
|
||||
}
|
||||
const gallery = ref<HTMLElement>()
|
||||
const columnCount = ref(1)
|
||||
@@ -202,31 +207,20 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
const mkdir = (doc: Doc, name: string) => {
|
||||
const control = connect(controlUrl, {
|
||||
open() {
|
||||
control.send(
|
||||
JSON.stringify({
|
||||
op: 'mkdir',
|
||||
path: `${doc.loc}/${name}`
|
||||
})
|
||||
)
|
||||
},
|
||||
message(ev: MessageEvent) {
|
||||
const msg = JSON.parse(ev.data)
|
||||
if ('error' in msg) {
|
||||
console.error('Mkdir failed', msg.error.message, msg.error)
|
||||
editing.value = null
|
||||
} else {
|
||||
console.log('mkdir', msg)
|
||||
router.push(doc.urlrouter)
|
||||
}
|
||||
}
|
||||
})
|
||||
const mkdir = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||
try {
|
||||
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
router.push(doc.urlrouter)
|
||||
} catch (err) {
|
||||
console.error('Mkdir failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
||||
}
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
@@ -312,24 +306,17 @@ const copyImage = async (doc: Doc) => {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = (doc: Doc) => {
|
||||
const deleteFile = async (doc: Doc) => {
|
||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
store.hideDoc(path)
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Delete failed', res.error)
|
||||
store.unhideDoc(path)
|
||||
store.showToast(res.error.message || 'Delete failed')
|
||||
} else if (res.status === 'ack') {
|
||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
|
||||
try {
|
||||
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||
} catch (err) {
|
||||
console.error('Delete failed', err)
|
||||
store.unhideDoc(path)
|
||||
store.showToast(err instanceof Error ? err.message : 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,10 @@ const settingsMenu = (e: Event) => {
|
||||
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
|
||||
}
|
||||
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: '🔑 API Tokens', onClick: () => { store.dialog = 'tokens' }})
|
||||
}
|
||||
|
||||
if (store.user.privileged) {
|
||||
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {connect, controlUrl} from '@/repositories/WS'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
@@ -49,6 +49,18 @@ const navigateTo = (path: string) => {
|
||||
router.push('/' + path)
|
||||
}
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
const data = await res.json()
|
||||
return data.message || data.detail || `${res.status} ${res.statusText}`
|
||||
} catch {
|
||||
return `${res.status} ${res.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate long names to reasonable length
|
||||
const truncateName = (name: string, maxLen = 20): string => {
|
||||
if (name.length <= maxLen) return name
|
||||
@@ -115,43 +127,43 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const op = (opName: string, dst?: string) => {
|
||||
const op = async (opName: string, dst?: string) => {
|
||||
const sel = store.selectedFiles
|
||||
const keys = sel.keys
|
||||
const paths = sel.keys.map(key => {
|
||||
const doc = sel.docs[key]!
|
||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
})
|
||||
const msg = {
|
||||
op: opName,
|
||||
sel: paths
|
||||
}
|
||||
// @ts-ignore
|
||||
if (dst !== undefined) msg.dst = dst
|
||||
|
||||
// Hide items being deleted or moved (optimistic update)
|
||||
if (opName === 'rm' || opName === 'mv') {
|
||||
for (const path of paths) store.hideDoc(path)
|
||||
}
|
||||
const control = connect(controlUrl, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
if ('error' in res) {
|
||||
console.error('Control socket error', msg, res.error)
|
||||
store.error = res.error.message
|
||||
// Restore hidden items on error
|
||||
if (opName === 'rm' || opName === 'mv') {
|
||||
for (const path of paths) store.unhideDoc(path)
|
||||
}
|
||||
return
|
||||
} else if (res.status === 'ack') {
|
||||
console.log('Control ack OK', res)
|
||||
control.close()
|
||||
store.selected.clear()
|
||||
return
|
||||
} else console.log('Unknown control response', msg, res)
|
||||
|
||||
try {
|
||||
if (opName === 'rm') {
|
||||
for (const path of paths) {
|
||||
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
}
|
||||
} else if (opName === 'mv' || opName === 'cp') {
|
||||
if (keys.length === 0) throw new Error('No selected files')
|
||||
const dstUrl = dst ? filesUrl(dst) : '/files/'
|
||||
const query = `${opName}=${keys.join('+')}`
|
||||
const res = await apiFetch(`${dstUrl}?${query}`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} else {
|
||||
throw new Error(`Unsupported operation: ${opName}`)
|
||||
}
|
||||
|
||||
store.selected.clear()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error('REST file operation failed', opName, err)
|
||||
store.error = message
|
||||
if (opName === 'rm' || opName === 'mv') {
|
||||
for (const path of paths) store.unhideDoc(path)
|
||||
}
|
||||
})
|
||||
control.onopen = () => {
|
||||
control.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,14 +188,14 @@ const deleteUserAction = async (username: string) => {
|
||||
}
|
||||
|
||||
const copySuccess = async (isButtonClick: boolean = false) => {
|
||||
const passwordMatch = success.value.match(/(?:Password|New password): (.+)/)
|
||||
const passwordMatch = success.value.match(/(?:Password|New password|Key): (.+)/)
|
||||
if (passwordMatch) {
|
||||
await navigator.clipboard.writeText(passwordMatch[1]!)
|
||||
if (isButtonClick) {
|
||||
// Show "Copied!" indication on button
|
||||
copyButtonText.value = '✅ Copied!'
|
||||
// Hide password and button immediately after copying
|
||||
const baseMessage = success.value.replace(/(?:Password|New password): .+/, 'Password copied to clipboard!')
|
||||
// Hide password/key and button immediately after copying
|
||||
const baseMessage = success.value.replace(/(?:Password|New password|Key): .+/, 'Copied to clipboard!')
|
||||
success.value = baseMessage
|
||||
// Hide the entire message after 3 seconds
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<ModalDialog name=tokens title="My API Tokens">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else>
|
||||
<p class="hint">Create tokens to access Cista from scripts or other apps. Tokens are tied to your account.</p>
|
||||
|
||||
<!-- Creation form -->
|
||||
<div v-if="mode === 'creating'" class="create-form">
|
||||
<label for="token-name">Token name (optional)</label>
|
||||
<input
|
||||
id="token-name"
|
||||
v-model="newTokenName"
|
||||
type="text"
|
||||
placeholder="e.g. backup-script"
|
||||
@keyup.enter="submitCreate"
|
||||
ref="nameInput"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<button @click="submitCreate" class="button primary" :disabled="creating">Create</button>
|
||||
<button @click="cancelCreate" class="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creation result -->
|
||||
<div v-else-if="mode === 'created' && createdToken" class="created-result">
|
||||
<p class="success-title">✅ Token created</p>
|
||||
<p class="hint">Copy this URL — it will not be shown again.</p>
|
||||
<div class="url-box">
|
||||
<code class="token-url">{{ createdToken.url }}</code>
|
||||
<button @click="copyUrl" class="button small">{{ copyButtonText }}</button>
|
||||
</div>
|
||||
<p class="hint">Use it like: <code>curl {{ createdToken.url }}/...</code></p>
|
||||
<div class="form-actions">
|
||||
<button @click="finishCreate" class="button primary">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token list -->
|
||||
<div v-else>
|
||||
<button @click="startCreate" class="button" title="Add new token">➕ Add Token</button>
|
||||
<table v-if="tokens.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in tokens" :key="token.id">
|
||||
<td>{{ token.name || 'Unnamed' }}</td>
|
||||
<td>{{ formatDate(token.created) }}</td>
|
||||
<td>
|
||||
<button @click="deleteTokenAction(token.id)" class="button small danger" title="Revoke token">🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty">You have no API tokens.</p>
|
||||
</div>
|
||||
|
||||
<div class="dialog-buttons">
|
||||
<button @click="close" class="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { listTokens, createToken, deleteToken } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
interface Token {
|
||||
id: string
|
||||
username: string
|
||||
sso_user_id: string
|
||||
name: string
|
||||
created: number
|
||||
}
|
||||
|
||||
interface CreatedToken extends Token {
|
||||
key: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const store = useMainStore()
|
||||
const loading = ref(true)
|
||||
const tokens = ref<Token[]>([])
|
||||
const mode = ref<'list' | 'creating' | 'created'>('list')
|
||||
const newTokenName = ref('')
|
||||
const creating = ref(false)
|
||||
const createdToken = ref<CreatedToken | null>(null)
|
||||
const copyButtonText = ref('📋')
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const close = () => {
|
||||
store.dialog = ''
|
||||
resetCreate()
|
||||
}
|
||||
|
||||
const resetCreate = () => {
|
||||
mode.value = 'list'
|
||||
newTokenName.value = ''
|
||||
creating.value = false
|
||||
createdToken.value = null
|
||||
copyButtonText.value = '📋'
|
||||
}
|
||||
|
||||
const loadTokens = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await listTokens()
|
||||
tokens.value = data.tokens
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
store.showToast(httpError.message || 'Failed to load tokens')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startCreate = () => {
|
||||
mode.value = 'creating'
|
||||
nextTick(() => nameInput.value?.focus())
|
||||
}
|
||||
|
||||
const cancelCreate = () => {
|
||||
resetCreate()
|
||||
}
|
||||
|
||||
const ensureFilesBaseUrl = (url: string) => {
|
||||
const trimmed = url.replace(/\/+$/, '')
|
||||
if (trimmed.endsWith('/files')) return trimmed
|
||||
return `${trimmed}/files`
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (creating.value) return
|
||||
creating.value = true
|
||||
try {
|
||||
const result = await createToken(newTokenName.value)
|
||||
await loadTokens()
|
||||
if (result.url) {
|
||||
createdToken.value = {
|
||||
...(result as CreatedToken),
|
||||
url: ensureFilesBaseUrl((result as CreatedToken).url),
|
||||
}
|
||||
mode.value = 'created'
|
||||
}
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
store.showToast(httpError.message || 'Failed to create token')
|
||||
mode.value = 'list'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const finishCreate = () => {
|
||||
resetCreate()
|
||||
}
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!createdToken.value) return
|
||||
await navigator.clipboard.writeText(createdToken.value.url)
|
||||
copyButtonText.value = '✅ Copied!'
|
||||
setTimeout(() => {
|
||||
copyButtonText.value = '📋'
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const deleteTokenAction = async (tokenId: string) => {
|
||||
if (!confirm('Revoke this token? It will no longer work.')) return
|
||||
try {
|
||||
await deleteToken(tokenId)
|
||||
await loadTokens()
|
||||
} catch (e) {
|
||||
const httpError = e as ISimpleError
|
||||
store.showToast(httpError.message || 'Failed to revoke token')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
// Load tokens when dialog opens
|
||||
watch(() => store.dialog, (newVal) => {
|
||||
if (newVal === 'tokens') {
|
||||
resetCreate()
|
||||
loadTokens()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.empty {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.create-form {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.create-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #444;
|
||||
}
|
||||
.create-form input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #888;
|
||||
border-radius: 0.25rem;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.create-form input:focus {
|
||||
outline: none;
|
||||
border-color: #f80;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.created-result {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.success-title {
|
||||
color: #080;
|
||||
font-weight: bold;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.url-box {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
background: #f0f0f0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.token-url {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
font-size: 0.875rem;
|
||||
color: #222;
|
||||
}
|
||||
.dialog-buttons {
|
||||
margin-top: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -65,3 +65,20 @@ export async function getServerConfig() {
|
||||
const data = await Client.get('/api/config')
|
||||
return data as { name: string, public: boolean }
|
||||
}
|
||||
|
||||
export const url_tokens = '/api/tokens'
|
||||
|
||||
export async function listTokens() {
|
||||
const data = await Client.get(url_tokens)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createToken(name: string) {
|
||||
const data = await Client.post(url_tokens, { name })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteToken(tokenId: string) {
|
||||
const data = await Client.delete(`${url_tokens}/${tokenId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMainStore } from "@/stores/main"
|
||||
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
|
||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||
|
||||
export const controlUrl = '/api/control'
|
||||
export const watchUrl = '/api/watch'
|
||||
|
||||
let tree = [] as FileEntry[]
|
||||
|
||||
@@ -80,7 +80,7 @@ export const useMainStore = defineStore('main', {
|
||||
authInProgress: false,
|
||||
cursor: '' as string,
|
||||
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
||||
uprogress: {} as any,
|
||||
dprogress: {} as any,
|
||||
prefs: {
|
||||
|
||||
Reference in New Issue
Block a user