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.
287 lines
8.8 KiB
Vue
287 lines
8.8 KiB
Vue
<template>
|
||
<ModalDialog name=usermgmt title="Admin Settings">
|
||
<div v-if="loading" class="loading">Loading...</div>
|
||
<div v-else>
|
||
<h3>Server Settings</h3>
|
||
<div class="form-row">
|
||
<label for="serverName">Server name</label>
|
||
<div class="input-with-hint">
|
||
<input
|
||
type="text"
|
||
id="serverName"
|
||
v-model="serverSettings.name"
|
||
@input="debouncedUpdateServerName"
|
||
:placeholder="store.server.name"
|
||
/>
|
||
<small>Leave empty to use the share folder name</small>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<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="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>
|
||
<div class="dialog-buttons">
|
||
<button @click="close" class="button">Close</button>
|
||
</div>
|
||
</div>
|
||
</ModalDialog>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, reactive, onMounted, watch } from 'vue'
|
||
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
|
||
import type { ISimpleError } from '@/repositories/Client'
|
||
import { useMainStore } from '@/stores/main'
|
||
|
||
interface User {
|
||
username: string
|
||
privileged: boolean
|
||
lastSeen: number
|
||
}
|
||
|
||
const store = useMainStore()
|
||
const loading = ref(true)
|
||
const users = ref<User[]>([])
|
||
const success = ref('')
|
||
const copyButtonText = ref('📋')
|
||
const serverSettings = reactive({
|
||
public: false,
|
||
name: '',
|
||
})
|
||
|
||
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const close = () => {
|
||
store.dialog = ''
|
||
success.value = ''
|
||
}
|
||
|
||
const loadUsers = async () => {
|
||
try {
|
||
loading.value = true
|
||
const data = await listUsers()
|
||
users.value = data.users
|
||
} catch (e) {
|
||
const httpError = e as ISimpleError
|
||
store.showToast(httpError.message || 'Failed to load users')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const addUser = async () => {
|
||
const username = window.prompt('Enter username for new user:')
|
||
if (!username || !username.trim()) return
|
||
try {
|
||
success.value = ''
|
||
const result = await createUser(username.trim(), undefined, false)
|
||
await loadUsers()
|
||
if (result.password) {
|
||
success.value = `User ${username.trim()} created. Password: ${result.password}`
|
||
}
|
||
} catch (e) {
|
||
const httpError = e as ISimpleError
|
||
store.showToast(httpError.message || 'Failed to add user')
|
||
}
|
||
}
|
||
|
||
const toggleAdmin = async (user: User, event: Event) => {
|
||
const target = event.target as HTMLInputElement
|
||
try {
|
||
await updateUser(user.username, { privileged: target.checked })
|
||
user.privileged = target.checked
|
||
} catch (e) {
|
||
const httpError = e as ISimpleError
|
||
store.showToast(httpError.message || 'Failed to update user')
|
||
target.checked = user.privileged // revert
|
||
}
|
||
}
|
||
|
||
const renameUser = async (user: User) => {
|
||
const newName = window.prompt('Enter new username:', user.username)
|
||
if (!newName || !newName.trim() || newName.trim() === user.username) return
|
||
// 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 {
|
||
success.value = ''
|
||
const result = await createUser(newName.trim(), undefined, user.privileged)
|
||
await deleteUser(user.username)
|
||
await loadUsers()
|
||
if (result.password) {
|
||
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
|
||
}
|
||
} catch (e) {
|
||
const httpError = e as ISimpleError
|
||
store.showToast(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 {
|
||
success.value = ''
|
||
const result = await updateUser(user.username, { password: "" })
|
||
if (result.password) {
|
||
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||
}
|
||
} catch (e) {
|
||
const httpError = e as ISimpleError
|
||
store.showToast(httpError.message || 'Failed to reset password')
|
||
}
|
||
}
|
||
|
||
const deleteUserAction = async (username: string) => {
|
||
if (!confirm(`Delete user ${username}?`)) return
|
||
try {
|
||
await deleteUser(username)
|
||
await loadUsers()
|
||
} catch (e) {
|
||
const httpError = e as ISimpleError
|
||
store.showToast(httpError.message || 'Failed to delete user')
|
||
}
|
||
}
|
||
|
||
const copySuccess = async (isButtonClick: boolean = false) => {
|
||
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/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(() => {
|
||
success.value = ''
|
||
copyButtonText.value = '📋'
|
||
}, 3000)
|
||
} else {
|
||
// Just hide the message when clicking elsewhere
|
||
success.value = ''
|
||
}
|
||
}
|
||
}
|
||
|
||
const updateServerSettings = async () => {
|
||
try {
|
||
success.value = ''
|
||
await updatePublic(serverSettings.public)
|
||
// Update store
|
||
store.server.public = serverSettings.public
|
||
success.value = 'Server settings updated'
|
||
} catch (e) {
|
||
const httpError = e as ISimpleError
|
||
store.showToast(httpError.message || 'Failed to update settings')
|
||
}
|
||
}
|
||
|
||
const updateServerNameSetting = async () => {
|
||
try {
|
||
const result = await updateServerName(serverSettings.name)
|
||
// Update store with the effective name returned by the server
|
||
store.server.name = result.name
|
||
} catch (e) {
|
||
const httpError = e as ISimpleError
|
||
store.showToast(httpError.message || 'Failed to update server name')
|
||
}
|
||
}
|
||
|
||
const debouncedUpdateServerName = () => {
|
||
if (nameDebounceTimer) clearTimeout(nameDebounceTimer)
|
||
nameDebounceTimer = setTimeout(updateServerNameSetting, 400)
|
||
}
|
||
|
||
// Load server config from admin API
|
||
const loadServerConfig = async () => {
|
||
try {
|
||
const config = await getServerConfig()
|
||
serverSettings.name = config.name
|
||
serverSettings.public = config.public
|
||
} catch (e) {
|
||
// Fallback to store values if API fails
|
||
serverSettings.public = store.server.public || false
|
||
serverSettings.name = ''
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
serverSettings.public = store.server.public || false
|
||
serverSettings.name = ''
|
||
loading.value = false
|
||
})
|
||
|
||
// Load users and config when dialog opens
|
||
watch(() => store.dialog, (newVal) => {
|
||
if (newVal === 'usermgmt') {
|
||
loadServerConfig()
|
||
if (!store.server.paskia) {
|
||
loadUsers()
|
||
}
|
||
}
|
||
})
|
||
|
||
watch(() => store.server.public, (newVal) => {
|
||
serverSettings.public = newVal || false
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||
.input-with-hint {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
.input-with-hint small {
|
||
color: #666;
|
||
font-size: 0.75rem;
|
||
}
|
||
</style>
|