Implement web-based user management / admin setup. #8

Merged
LeoVasanko merged 1 commits from admin-ui into main 2025-10-01 01:10:33 +01:00
7 changed files with 427 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ from sanic import Blueprint, html, json, redirect
from sanic.exceptions import BadRequest, Forbidden, Unauthorized from sanic.exceptions import BadRequest, Forbidden, Unauthorized
from cista import config, session from cista import config, session
from cista.util import pwgen
_argon = argon2.PasswordHasher() _argon = argon2.PasswordHasher()
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$") _droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
@@ -191,3 +192,91 @@ async def change_password(request):
res = json({"message": "Password updated"}) res = json({"message": "Password updated"})
session.create(res, username) session.create(res, username)
return res return res
@bp.get("/users")
async def list_users(request):
verify(request, privileged=True)
users = []
for name, user in config.config.users.items():
users.append(
{
"username": name,
"privileged": user.privileged,
"lastSeen": user.lastSeen,
}
)
return json({"users": users})
@bp.post("/users")
async def create_user(request):
verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
password = request.json.get("password")
privileged = request.json.get("privileged", False)
else:
username = request.form["username"][0]
password = request.form.get("password", [None])[0]
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
if not username or not username.isidentifier():
raise ValueError("Invalid username")
except (KeyError, ValueError) as e:
raise BadRequest(str(e)) from e
if username in config.config.users:
raise BadRequest("User already exists")
if not password:
password = pwgen.generate()
changes = {"privileged": privileged}
changes["hash"] = _argon.hash(_pwnorm(password))
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} created", "password": password})
@bp.put("/users/<username>")
async def update_user(request, username):
verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
changes = request.json
else:
changes = {}
if "password" in request.form:
changes["password"] = request.form["password"][0]
if "privileged" in request.form:
changes["privileged"] = request.form["privileged"][0].lower() == "true"
except KeyError as e:
raise BadRequest("Missing fields") from e
password_response = None
if "password" in changes:
if changes["password"] == "":
changes["password"] = pwgen.generate()
password_response = changes["password"]
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
del changes["password"]
if not changes:
return json({"message": "No changes"})
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
response = {"message": f"User {username} updated"}
if password_response:
response["password"] = password_response
return json(response)
@bp.put("/config/public")
async def update_public(request):
verify(request, privileged=True)
try:
public = request.json["public"]
except KeyError:
raise BadRequest("Missing public field") from None
config.update_config({"public": public})
return json({"message": "Public setting updated"})

View File

@@ -1,6 +1,7 @@
<template> <template>
<LoginModal /> <LoginModal />
<SettingsModal /> <SettingsModal />
<UserManagementModal />
<header> <header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query"> <HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" /> <HeaderSelected :path="path.pathList" />
@@ -28,6 +29,7 @@ import { computed } from 'vue'
import Router from '@/router/index' import Router from '@/router/index'
import type { SortOrder } from './utils/docsort' import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue' import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
interface Path { interface Path {
path: string path: string

View File

@@ -73,7 +73,10 @@ watchEffect(() => {
const settingsMenu = (e: Event) => { const settingsMenu = (e: Event) => {
// show the context menu // show the context menu
const items = [] const items = []
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }}) 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) { if (store.user.isLoggedIn) {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() }) items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
} else { } else {

View File

@@ -0,0 +1,250 @@
<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">
<input
id="publicServer"
type="checkbox"
v-model="serverSettings.public"
@change="updateServerSettings"
/>
<label for="publicServer">Publicly accessible without any user account.</label>
</div>
<h3>Users</h3>
<button @click="addUser" class="button" title="Add new user"> Add User</button>
<div v-if="success" class="success-message">
{{ success }}
<button @click="copySuccess" class="button small" title="Copy to clipboard"><EFBFBD></button>
</div>
<table class="user-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>
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
<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 } 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 error = ref('')
const success = ref('')
const serverSettings = reactive({
public: false
})
const close = () => {
store.dialog = ''
error.value = ''
success.value = ''
}
const loadUsers = async () => {
try {
loading.value = true
const data = await listUsers()
users.value = data.users
} catch (e) {
const httpError = e as ISimpleError
error.value = 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 {
error.value = ''
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
error.value = httpError.message || 'Failed to add user'
}
}
const toggleAdmin = async (user: User, event: Event) => {
const target = event.target as HTMLInputElement
try {
error.value = ''
await updateUser(user.username, { privileged: target.checked })
user.privileged = target.checked
} catch (e) {
const httpError = e as ISimpleError
error.value = 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 {
error.value = ''
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
error.value = 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 {
error.value = ''
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
error.value = httpError.message || 'Failed to reset password'
}
}
const deleteUserAction = async (username: string) => {
if (!confirm(`Delete user ${username}?`)) return
try {
error.value = ''
await deleteUser(username)
await loadUsers()
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to delete user'
}
}
const copySuccess = async () => {
const passwordMatch = success.value.match(/Password: (.+)/)
if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1])
// Maybe flash or something, but for now just copy
}
}
const updateServerSettings = async () => {
try {
error.value = ''
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
error.value = httpError.message || 'Failed to update settings'
}
}
onMounted(() => {
serverSettings.public = store.server.public
loadUsers()
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal
})
</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>

View File

@@ -1,4 +1,20 @@
class ClientClass { 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()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
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> { async post(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
@@ -17,6 +33,40 @@ class ClientClass {
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message) if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg 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()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
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()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
} }
export const Client = new ClientClass() export const Client = new ClientClass()

View File

@@ -24,3 +24,34 @@ export async function changePassword(username: string, passwordChange: string, p
}) })
return data return data
} }
export const url_users = '/users'
export async function listUsers() {
const data = await Client.get(url_users)
return data
}
export async function createUser(username: string, password?: string, privileged?: boolean) {
const data = await Client.post(url_users, {
username,
password,
privileged
})
return data
}
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
const data = await Client.put(`${url_users}/${username}`, changes)
return data
}
export async function deleteUser(username: string) {
const data = await Client.delete(`${url_users}/${username}`)
return data
}
export async function updatePublic(publicFlag: boolean) {
const data = await Client.put('/config/public', { public: publicFlag })
return data
}

View File

@@ -18,7 +18,7 @@ export const useMainStore = defineStore({
connected: false, connected: false,
cursor: '' as string, cursor: '' as string,
server: {} as Record<string, any>, server: {} as Record<string, any>,
dialog: '' as '' | 'login' | 'settings', dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
uprogress: {} as any, uprogress: {} as any,
dprogress: {} as any, dprogress: {} as any,
prefs: { prefs: {