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. Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user