ba199f24bd
- Defer quiet-hours dispatches into new deferred_dispatch table; drain job + periodic catch-up scan re-fire at window end with coalescing on (link, event_type, collection_id). - Add ON DELETE SET NULL migration on event_log_id and partial unique index on (link_id, collection_id, event_type) WHERE status='pending'. - Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe URL validation, settings UI cassette, and scheduled polling. - Replace importlib-only version lookup with version.py helper that prefers the higher of installed metadata vs source pyproject so stale editable dev installs stop misreporting. - Aurora frontend polish: MetaStrip component, ReleaseCassette, EventDetailModal expansion, and i18n additions.
228 lines
9.1 KiB
Svelte
228 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { api, parseDate } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { getAuth } from '$lib/auth.svelte';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import Card from '$lib/components/Card.svelte';
|
|
import Loading from '$lib/components/Loading.svelte';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import Button from '$lib/components/Button.svelte';
|
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
|
import type { User } from '$lib/types';
|
|
|
|
const auth = getAuth();
|
|
let users = $state<User[]>([]);
|
|
let showForm = $state(false);
|
|
let form = $state({ username: '', password: '', role: 'user' });
|
|
let error = $state('');
|
|
let loaded = $state(false);
|
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
|
|
|
// Admin reset password
|
|
let resetUserId = $state<number | null>(null);
|
|
let resetUsername = $state('');
|
|
let resetPassword = $state('');
|
|
let resetMsg = $state('');
|
|
let resetSuccess = $state(false);
|
|
|
|
// Admin edit username/role
|
|
let editUserId = $state<number | null>(null);
|
|
let editUsername = $state('');
|
|
let editRole = $state('user');
|
|
let editMsg = $state('');
|
|
let editSuccess = $state(false);
|
|
|
|
onMount(load);
|
|
async function load() {
|
|
try { users = await api('/users'); }
|
|
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
|
finally { loaded = true; }
|
|
}
|
|
|
|
async function create(e: SubmitEvent) {
|
|
e.preventDefault(); error = '';
|
|
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
|
|
catch (err: any) { error = err.message; snackError(err.message); }
|
|
}
|
|
function remove(id: number) {
|
|
confirmDelete = {
|
|
id,
|
|
onconfirm: async () => {
|
|
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
|
|
catch (err: any) { error = err.message; snackError(err.message); }
|
|
finally { confirmDelete = null; }
|
|
}
|
|
};
|
|
}
|
|
function openResetPassword(user: any) {
|
|
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
|
}
|
|
function openEditUser(user: any) {
|
|
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
|
}
|
|
async function saveUserEdit(e: SubmitEvent) {
|
|
e.preventDefault(); editMsg = ''; editSuccess = false;
|
|
try {
|
|
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
|
editMsg = t('snack.userUpdated');
|
|
editSuccess = true;
|
|
snackSuccess(editMsg);
|
|
await load();
|
|
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
|
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
|
|
}
|
|
async function resetUserPassword(e: SubmitEvent) {
|
|
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
|
try {
|
|
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
|
resetMsg = t('common.passwordChanged');
|
|
resetSuccess = true;
|
|
snackSuccess(t('snack.passwordChanged'));
|
|
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
|
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
|
|
}
|
|
|
|
function userTiles(user: User): MetaTile[] {
|
|
const tiles: MetaTile[] = [];
|
|
const isAdmin = user.role === 'admin';
|
|
tiles.push({
|
|
icon: isAdmin ? 'mdiShieldCrownOutline' : 'mdiAccountOutline',
|
|
label: isAdmin ? t('users.roleAdmin') : t('users.roleUser'),
|
|
tone: isAdmin ? 'orchid' : 'sky',
|
|
});
|
|
tiles.push({
|
|
icon: 'mdiCalendarOutline',
|
|
label: parseDate(user.created_at).toLocaleDateString(),
|
|
hint: t('users.joined'),
|
|
tone: 'lavender',
|
|
mono: true,
|
|
});
|
|
if (user.id === auth.user?.id) {
|
|
tiles.push({
|
|
icon: 'mdiAccountStar',
|
|
label: t('users.you', 'you'),
|
|
tone: 'mint',
|
|
});
|
|
}
|
|
return tiles;
|
|
}
|
|
</script>
|
|
|
|
<PageHeader
|
|
title={t('users.title')}
|
|
emphasis={t('users.titleEmphasis')}
|
|
description={t('users.description')}
|
|
crumb={t('crumbs.systemAccess')}
|
|
count={users.length}
|
|
countLabel={t('users.countLabel')}
|
|
>
|
|
<Button size="sm" onclick={() => showForm = !showForm}>
|
|
{showForm ? t('users.cancel') : t('users.addUser')}
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
{#if !loaded}<Loading />{:else}
|
|
|
|
{#if showForm}
|
|
<Card class="mb-6">
|
|
{#if error}<ErrorBanner message={error} />{/if}
|
|
<form onsubmit={create} class="space-y-3">
|
|
<div>
|
|
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
|
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
|
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
|
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
|
<option value="user">{t('users.roleUser')}</option>
|
|
<option value="admin">{t('users.roleAdmin')}</option>
|
|
</select>
|
|
</div>
|
|
<Button type="submit">{t('users.create')}</Button>
|
|
</form>
|
|
</Card>
|
|
{/if}
|
|
|
|
{#if users.length === 0}
|
|
<Card>
|
|
<EmptyState icon="mdiAccountGroup" message={t('users.noUsers')} />
|
|
</Card>
|
|
{:else}
|
|
<div class="list-stack stagger-children">
|
|
{#each users as user}
|
|
<Card hover>
|
|
<div class="list-row">
|
|
<div class="list-row__identity">
|
|
<p class="font-medium truncate">{user.username}</p>
|
|
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
|
</div>
|
|
<MetaStrip tiles={userTiles(user)} />
|
|
<div class="list-row__actions">
|
|
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
|
{#if user.id !== auth.user?.id}
|
|
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
|
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{/if}
|
|
|
|
<!-- Admin reset password modal -->
|
|
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
|
<form onsubmit={resetUserPassword} class="space-y-3">
|
|
<div>
|
|
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
|
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
{#if resetMsg}
|
|
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
|
{/if}
|
|
<Button type="submit" class="w-full">
|
|
{t('common.save')}
|
|
</Button>
|
|
</form>
|
|
</Modal>
|
|
|
|
<!-- Admin edit username/role modal -->
|
|
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
|
<form onsubmit={saveUserEdit} class="space-y-3">
|
|
<div>
|
|
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
|
<input id="edit-username" bind:value={editUsername} required
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
</div>
|
|
<div>
|
|
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
|
<select id="edit-role" bind:value={editRole}
|
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
|
<option value="user">{t('users.roleUser')}</option>
|
|
<option value="admin">{t('users.roleAdmin')}</option>
|
|
</select>
|
|
</div>
|
|
{#if editMsg}
|
|
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
|
{/if}
|
|
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
|
</form>
|
|
</Modal>
|
|
|
|
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|