307 lines
8.9 KiB
Vue
307 lines
8.9 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 type { ISimpleError } from '@/repositories/Client'
|
||
import {
|
||
createUser,
|
||
deleteUser,
|
||
getServerConfig,
|
||
listUsers,
|
||
updatePublic,
|
||
updateServerName,
|
||
updateUser
|
||
} from '@/repositories/User'
|
||
import { useMainStore } from '@/stores/main'
|
||
import { onMounted, reactive, ref, watch } from 'vue'
|
||
|
||
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>
|