a7a2b4efa4
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
/ search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
to POST /api/search/metadata with personIds (fixes /person command and
auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
image when missing (falls back to any asset type); failures do not fail the
rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
+ backfill. Status query filters by user_id directly; Immich/webhook paths
emit user_id explicitly. action_runner writes an action_success/partial/
failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
(ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
pending_restore.json; lifespan hook applies on next startup and archives
under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
(limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
TelegramChat.language_override per chat instead of applying the first
receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
track_assets_removed default False.
Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
create forms (trackers, command-trackers, targets, template/command
configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.
Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
notification_tracker).
- command_tracker_listener: + allowed_album_ids.
194 lines
8.2 KiB
Svelte
194 lines
8.2 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 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); }
|
|
}
|
|
</script>
|
|
|
|
<PageHeader title={t('users.title')} description={t('users.description')}>
|
|
<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="space-y-3 stagger-children">
|
|
{#each users as user}
|
|
<Card hover>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="font-medium">{user.username}</p>
|
|
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<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} />
|