Files
Oxicloud/frontend/src/routes/admin/+page.svelte
T

2517 lines
67 KiB
Svelte
Raw Normal View History

2026-06-17 17:06:30 -06:00
<script lang="ts">
2026-06-17 22:30:56 -06:00
import { errorMessage, errorToast } from '$lib/utils/errors';
2026-06-17 17:06:30 -06:00
import {
2026-06-17 22:07:18 -06:00
clearPluginLogs,
2026-06-17 17:06:30 -06:00
createUser,
deletePlugin,
deleteUser,
2026-06-17 22:07:18 -06:00
getDashboard,
getMigration,
getOidcSettings,
getPluginLogs,
getPluginRetention,
getSmtpInfo,
getStorageSettings,
installPlugin,
2026-06-17 17:06:30 -06:00
listPlugins,
listUsers,
2026-06-17 22:07:18 -06:00
migrationAction,
2026-06-17 17:06:30 -06:00
resetUserPassword,
2026-06-17 22:07:18 -06:00
saveOidc,
savePluginRetention,
saveStorage,
sendSmtpTest,
2026-06-17 17:06:30 -06:00
setPluginEnabled,
2026-06-17 22:07:18 -06:00
setRegistrationEnabled,
2026-06-17 17:06:30 -06:00
setUserActive,
setUserQuota,
setUserRole,
2026-06-17 22:07:18 -06:00
testOidc,
testStorage,
verifyMigration,
type AdminDashboard,
type MigrationStatus,
type MigrationVerifyResult,
type OidcSettings,
type OidcTestResult,
type PluginInfo,
type PluginLogEntry,
type PluginRetention,
type SmtpInfo,
type SmtpTestResult,
type StorageSettings,
type StorageTestResult
2026-06-17 17:06:30 -06:00
} from '$lib/api/endpoints/admin';
import type { User } from '$lib/api/types';
2026-06-17 22:07:18 -06:00
import Icon from '$lib/icons/Icon.svelte';
2026-06-17 17:06:30 -06:00
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
2026-06-17 22:07:18 -06:00
import { session } from '$lib/stores/session.svelte';
2026-06-17 17:06:30 -06:00
import { ui } from '$lib/stores/ui.svelte';
import { formatBytes } from '$lib/utils/format';
const PAGE_SIZE = 25;
2026-06-17 22:07:18 -06:00
const LOGS_PAGE_SIZE = 50;
2026-06-17 17:06:30 -06:00
2026-06-17 22:07:18 -06:00
/** The signed-in admin's id — used to disable destructive actions on self. */
const currentAdminId = $derived(session.user?.id ?? '');
/**
* Render an ISO timestamp as a coarse relative time ("3 min ago"). Ported
* from formatRelativeTime in static/js/core/formatters.js; an empty/missing
* value reads as "Never" (matching the OLD admin user table).
*/
function timeAgo(dateStr?: string | null): string {
if (!dateStr) return t('admin.never', 'Never');
const then = new Date(dateStr).getTime();
if (!Number.isFinite(then)) return t('admin.never', 'Never');
const secs = Math.round((Date.now() - then) / 1000);
if (secs < 60) return t('admin.time_just_now', 'just now');
const mins = Math.round(secs / 60);
if (mins < 60) return t('admin.time_min_ago', { n: mins }, '{{n}} min ago');
const hours = Math.round(mins / 60);
if (hours < 24) return t('admin.time_hour_ago', { n: hours }, '{{n}} h ago');
const days = Math.round(hours / 24);
if (days < 30) return t('admin.time_day_ago', { n: days }, '{{n}} d ago');
return new Date(dateStr).toLocaleDateString();
}
/** Quota unit options (bytes per unit) for the quota/create modals. */
const QUOTA_UNITS = [
{ value: 1024 ** 2, label: 'MB' },
{ value: 1024 ** 3, label: 'GB' },
{ value: 1024 ** 4, label: 'TB' }
] as const;
/* ── Styled confirm modal (replaces native confirm) ── */
let confirmState = $state<{ message: string; resolve: (ok: boolean) => void } | null>(null);
function showConfirm(message: string): Promise<boolean> {
return new Promise((resolve) => {
confirmState = { message, resolve };
});
}
function resolveConfirm(ok: boolean) {
confirmState?.resolve(ok);
confirmState = null;
}
type Tab = 'dashboard' | 'users' | 'plugins' | 'oidc' | 'storage' | 'smtp';
let tab = $state<Tab>('dashboard');
// Dashboard
let dashboard = $state<AdminDashboard | null>(null);
let dashboardError = $state<string | null>(null);
// SMTP
let smtp = $state<SmtpInfo | null>(null);
let smtpTo = $state('');
let smtpResult = $state<SmtpTestResult | null>(null);
let smtpSending = $state(false);
async function loadDashboard() {
dashboardError = null;
try {
dashboard = await getDashboard();
} catch (e) {
2026-06-17 22:30:56 -06:00
dashboardError = errorMessage(e);
2026-06-17 22:07:18 -06:00
}
}
async function toggleRegistration(enabled: boolean) {
try {
await setRegistrationEnabled(enabled);
if (dashboard) dashboard.registration_enabled = enabled;
} catch (e) {
reportError(e);
await loadDashboard();
}
}
async function loadSmtp() {
try {
smtp = await getSmtpInfo();
} catch (e) {
reportError(e);
}
}
async function runSmtpTest() {
if (!smtpTo.trim()) return;
smtpSending = true;
smtpResult = null;
try {
smtpResult = await sendSmtpTest(smtpTo.trim());
} catch (e) {
2026-06-17 22:30:56 -06:00
smtpResult = { success: false, message: errorMessage(e) };
2026-06-17 22:07:18 -06:00
} finally {
smtpSending = false;
}
}
// OIDC
let oidc = $state<(OidcSettings & { client_secret?: string }) | null>(null);
let oidcTest = $state<OidcTestResult | null>(null);
let oidcMsg = $state<{ text: string; ok: boolean } | null>(null);
let oidcSaving = $state(false);
async function loadOidc() {
try {
oidc = await getOidcSettings();
} catch (e) {
2026-06-17 22:30:56 -06:00
oidcMsg = { text: errorMessage(e), ok: false };
2026-06-17 22:07:18 -06:00
}
}
async function runOidcTest() {
if (!oidc?.issuer_url) return;
oidcTest = await testOidc(oidc.issuer_url);
if (oidcTest.success && oidcTest.provider_name_suggestion && !oidc.provider_name) {
oidc.provider_name = oidcTest.provider_name_suggestion;
}
}
async function doSaveOidc() {
if (!oidc) return;
oidcSaving = true;
oidcMsg = null;
try {
await saveOidc({
enabled: oidc.enabled,
issuer_url: oidc.issuer_url.trim(),
client_id: oidc.client_id.trim(),
client_secret: oidc.client_secret || null,
scopes: oidc.scopes || null,
auto_provision: oidc.auto_provision,
admin_groups: oidc.admin_groups || null,
disable_password_login: oidc.disable_password_login,
provider_name: oidc.provider_name || null
});
oidcMsg = { text: t('admin.settings_saved_ok', 'Settings saved.'), ok: true };
} catch (e) {
2026-06-17 22:30:56 -06:00
oidcMsg = { text: errorMessage(e), ok: false };
2026-06-17 22:07:18 -06:00
} finally {
oidcSaving = false;
}
}
// Storage
const STORAGE_PRESETS: Record<string, { endpoint: string; region: string; pathStyle: boolean }> =
{
custom: { endpoint: '', region: '', pathStyle: false },
aws: { endpoint: '', region: 'us-east-1', pathStyle: false },
backblaze: {
endpoint: 'https://s3.{region}.backblazeb2.com',
region: 'us-west-004',
pathStyle: false
},
'cloudflare-r2': {
endpoint: 'https://{accountId}.r2.cloudflarestorage.com',
region: 'auto',
pathStyle: true
},
minio: { endpoint: 'http://localhost:9000', region: 'us-east-1', pathStyle: true },
digitalocean: {
endpoint: 'https://{region}.digitaloceanspaces.com',
region: 'nyc3',
pathStyle: false
},
wasabi: {
endpoint: 'https://s3.{region}.wasabisys.com',
region: 'us-east-1',
pathStyle: false
}
};
let storage = $state<StorageSettings | null>(null);
let sForm = $state({
backend: 'local',
preset: 'custom',
endpoint: '',
bucket: '',
region: '',
accessKey: '',
secretKey: '',
pathStyle: false
});
let storageMsg = $state<{ text: string; ok: boolean } | null>(null);
let storageBusy = $state(false);
async function loadStorage() {
try {
storage = await getStorageSettings();
sForm = {
backend: storage.backend ?? 'local',
preset: 'custom',
endpoint: storage.s3_endpoint_url ?? '',
bucket: storage.s3_bucket ?? '',
region: storage.s3_region ?? '',
accessKey: '',
secretKey: '',
pathStyle: storage.s3_force_path_style ?? false
};
} catch (e) {
2026-06-17 22:30:56 -06:00
storageMsg = { text: errorMessage(e), ok: false };
2026-06-17 22:07:18 -06:00
}
}
function applyPreset() {
const p = STORAGE_PRESETS[sForm.preset];
if (!p) return;
if (p.endpoint) sForm.endpoint = p.endpoint;
if (p.region) sForm.region = p.region;
sForm.pathStyle = p.pathStyle;
}
function storageBody() {
return {
backend: sForm.backend,
s3_endpoint_url: sForm.endpoint.trim() || null,
s3_bucket: sForm.bucket.trim() || null,
s3_region: sForm.region.trim() || null,
s3_access_key: sForm.accessKey || null,
s3_secret_key: sForm.secretKey || null,
s3_force_path_style: sForm.pathStyle
};
}
async function doSaveStorage() {
storageBusy = true;
storageMsg = null;
try {
await saveStorage(storageBody());
storageMsg = { text: t('admin.storage_saved', 'Storage settings saved.'), ok: true };
await loadStorage();
} catch (e) {
2026-06-17 22:30:56 -06:00
storageMsg = { text: errorMessage(e), ok: false };
2026-06-17 22:07:18 -06:00
} finally {
storageBusy = false;
}
}
async function doTestStorage() {
storageBusy = true;
storageMsg = null;
try {
const r: StorageTestResult = await testStorage(storageBody());
const ok = r.connected ?? r.success ?? false;
if (ok) {
let text = t('admin.storage_test_success', 'Connection successful');
if (r.backend_type) text += ` (${r.backend_type})`;
if (r.available_bytes != null)
text += ` — ${formatBytes(r.available_bytes)} ${t('admin.available', 'available')}`;
storageMsg = { text, ok: true };
} else {
storageMsg = {
text: `${t('admin.storage_test_failure', 'Connection failed')}: ${r.message ?? ''}`,
ok: false
};
}
} catch (e) {
2026-06-17 22:30:56 -06:00
storageMsg = { text: errorMessage(e), ok: false };
2026-06-17 22:07:18 -06:00
} finally {
storageBusy = false;
}
}
// Migration
let migration = $state<MigrationStatus | null>(null);
let migrationTimer: ReturnType<typeof setInterval> | null = null;
function stopMigrationPoll() {
if (migrationTimer) {
clearInterval(migrationTimer);
migrationTimer = null;
}
}
async function loadMigration() {
try {
migration = await getMigration();
if (migration.status === 'running') {
if (!migrationTimer) migrationTimer = setInterval(loadMigration, 2000);
} else {
stopMigrationPoll();
}
} catch {
stopMigrationPoll();
}
}
async function doMigration(action: 'start' | 'pause' | 'resume' | 'complete') {
try {
await migrationAction(action);
await loadMigration();
} catch (e) {
reportError(e);
}
}
// Migration integrity verification (separate result panel).
let verifyResult = $state<MigrationVerifyResult | null>(null);
let verifyError = $state<string | null>(null);
let verifying = $state(false);
async function doVerify() {
verifying = true;
verifyResult = null;
verifyError = null;
try {
verifyResult = await verifyMigration(100);
} catch (e) {
2026-06-17 22:30:56 -06:00
verifyError = errorMessage(e);
2026-06-17 22:07:18 -06:00
} finally {
verifying = false;
}
}
const migrationPct = $derived(
migration && migration.total_blobs > 0
? Math.round((migration.migrated_blobs / migration.total_blobs) * 100)
: 0
);
/** Estimated minutes remaining, derived from throughput + average blob size. */
const migrationEtaMin = $derived.by(() => {
const m = migration;
if (!m || m.status !== 'running' || !m.throughput_bytes_per_sec) return null;
const remaining = m.total_blobs - m.migrated_blobs;
if (remaining <= 0 || m.migrated_blobs <= 0) return null;
const avgBlobSize = m.migrated_bytes / m.migrated_blobs;
const etaSecs = (remaining * avgBlobSize) / m.throughput_bytes_per_sec;
return Math.ceil(etaSecs / 60);
});
// Plugin logs
let logsPlugin = $state<PluginInfo | null>(null);
let logs = $state<PluginLogEntry[]>([]);
let logsLevel = $state('');
let logsSearch = $state('');
let logsLoading = $state(false);
let logsPage = $state(0);
let logsTotal = $state(0);
let logsLive = $state(true);
let logStream: EventSource | null = null;
/** Best-effort message text across the persisted (`msg`) and legacy shapes. */
function logMsg(e: PluginLogEntry): string {
return e.msg ?? e.message ?? '';
}
/** Kind column: outcome entries surface their reason, others read "log". */
function logKind(e: PluginLogEntry): string {
return e.kind === 'outcome' ? (e.reason ?? 'outcome') : 'log';
}
function stopLogStream() {
if (logStream) {
logStream.close();
logStream = null;
}
}
/** Open the SSE live tail for the current plugin (no-op when Live is off). */
function startLogStream() {
stopLogStream();
if (!logsPlugin || !logsLive) return;
const es = new EventSource(
`/api/admin/plugins/${encodeURIComponent(logsPlugin.id)}/logs/stream`,
{ withCredentials: true }
);
es.onmessage = (ev) => {
try {
onLiveLogEntry(JSON.parse(ev.data) as PluginLogEntry);
} catch {
/* ignore malformed frames */
}
};
// Fell behind the broadcast buffer — resync from the server.
es.addEventListener('lagged', () => void loadLogs());
logStream = es;
}
/**
* Prepend a streamed entry, but only on the newest page and when it passes
* the active filter — so the live tail never fights pagination.
*/
function onLiveLogEntry(entry: PluginLogEntry) {
if (logsPage !== 0) return;
if (logsLevel && (entry.level ?? '').toLowerCase() !== logsLevel.toLowerCase()) return;
if (logsSearch && !logMsg(entry).toLowerCase().includes(logsSearch.toLowerCase())) return;
logs = [entry, ...logs].slice(0, LOGS_PAGE_SIZE);
logsTotal += 1;
}
function toggleLive() {
if (logsLive) startLogStream();
else stopLogStream();
}
function logsPrev() {
if (logsPage > 0) {
logsPage--;
void loadLogs();
}
}
function logsNext() {
if ((logsPage + 1) * LOGS_PAGE_SIZE < logsTotal) {
logsPage++;
void loadLogs();
}
}
// Plugin detail (metadata + retention) — opened alongside logs
let retention = $state<PluginRetention | null>(null);
let retentionDays = $state(0);
let retentionMb = $state(0);
let retentionMsg = $state<string | null>(null);
async function openLogs(p: PluginInfo) {
logsPlugin = p;
retention = null;
retentionMsg = null;
logsPage = 0;
logsLevel = '';
logsSearch = '';
await Promise.all([loadLogs(), loadRetention(p.id)]);
startLogStream();
}
function closeLogs() {
stopLogStream();
logsPlugin = null;
logs = [];
logsTotal = 0;
logsPage = 0;
}
/** Reset to the first page (filter changed) then reload. */
function reloadLogsFromStart() {
logsPage = 0;
void loadLogs();
}
async function loadRetention(id: string) {
try {
retention = await getPluginRetention(id);
if (retention) {
retentionDays = retention.retention_days;
retentionMb = Math.round(retention.max_bytes / (1024 * 1024));
}
} catch {
/* retention is optional — leave unset on error */
}
}
async function saveRetention() {
if (!logsPlugin) return;
retentionMsg = null;
if (
!Number.isFinite(retentionDays) ||
retentionDays < 0 ||
!Number.isFinite(retentionMb) ||
retentionMb < 0
) {
retentionMsg = t('admin.plugins_retention_invalid', 'Enter non-negative numbers.');
return;
}
try {
await savePluginRetention(logsPlugin.id, {
retention_days: Math.round(retentionDays),
max_bytes: Math.round(retentionMb) * 1024 * 1024
});
retentionMsg = t('admin.plugins_retention_saved', 'Retention saved.');
} catch (e) {
2026-06-17 22:30:56 -06:00
retentionMsg = errorMessage(e);
2026-06-17 22:07:18 -06:00
}
}
async function purgeLogs() {
if (!logsPlugin) return;
if (
!(await showConfirm(t('admin.plugins_logs_confirm_clear', 'Clear all logs for this plugin?')))
)
return;
try {
await clearPluginLogs(logsPlugin.id);
logsPage = 0;
await loadLogs();
} catch (e) {
reportError(e);
}
}
// Plugin install (.zip upload)
let installing = $state(false);
let installMsg = $state<{ ok: boolean; text: string } | null>(null);
async function onInstallPlugin(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
installing = true;
installMsg = null;
try {
const info = await installPlugin(file);
installMsg = {
ok: true,
text: t('admin.plugins_installed', { name: info.name }, `Installed ${info.name}.`)
};
await loadPlugins();
} catch (err) {
2026-06-17 22:30:56 -06:00
installMsg = { ok: false, text: errorMessage(err) };
2026-06-17 22:07:18 -06:00
} finally {
installing = false;
input.value = '';
}
}
async function loadLogs() {
if (!logsPlugin) return;
logsLoading = true;
try {
const page = await getPluginLogs(logsPlugin.id, {
level: logsLevel,
search: logsSearch,
limit: LOGS_PAGE_SIZE,
offset: logsPage * LOGS_PAGE_SIZE
});
logs = page.entries;
logsTotal = page.total;
} catch (e) {
reportError(e);
} finally {
logsLoading = false;
}
}
2026-06-17 17:06:30 -06:00
// Users
let users = $state<User[]>([]);
let total = $state(0);
let pageIndex = $state(0);
let usersError = $state<string | null>(null);
let createOpen = $state(false);
2026-06-17 22:07:18 -06:00
let createError = $state<string | null>(null);
let creating = $state(false);
let newUser = $state({
username: '',
email: '',
password: '',
role: 'user',
quotaValue: 5,
quotaUnit: (1024 ** 3) as number
});
// Quota edit modal
let quotaModal = $state<{
userId: string;
username: string;
value: number;
unit: number;
} | null>(null);
// Reset-password modal
let resetModal = $state<{ userId: string; username: string } | null>(null);
let resetPassword = $state('');
let resetError = $state<string | null>(null);
let resetting = $state(false);
2026-06-17 17:06:30 -06:00
// Plugins
let plugins = $state<PluginInfo[]>([]);
let pluginsAvailable = $state(true);
let pluginsError = $state<string | null>(null);
async function loadUsers() {
usersError = null;
try {
const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE);
users = page.users;
total = page.total;
} catch (e) {
2026-06-17 22:30:56 -06:00
usersError = errorMessage(e);
2026-06-17 17:06:30 -06:00
}
}
async function loadPlugins() {
pluginsError = null;
try {
const res = await listPlugins();
pluginsAvailable = res.available;
plugins = res.plugins;
} catch (e) {
2026-06-17 22:30:56 -06:00
pluginsError = errorMessage(e);
2026-06-17 17:06:30 -06:00
}
}
function reportError(e: unknown) {
2026-06-17 22:30:56 -06:00
errorToast(e);
2026-06-17 17:06:30 -06:00
}
2026-06-17 22:07:18 -06:00
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
ui.notify(t('common.copied', 'Copied to clipboard'), 'success');
} catch {
ui.notify(t('common.copy_failed', 'Copy failed'), 'error');
}
}
/** True when a settings field is locked by an OXICLOUD_* env var. */
function isEnvLocked(overrides: string[] | undefined, field: string): boolean {
return Array.isArray(overrides) && overrides.includes(field);
}
/** True for the signed-in admin's own row — guards self-destructive actions. */
function isSelf(u: User): boolean {
return u.id === currentAdminId;
}
/** OIDC/SSO-provisioned account (no local password to reset). */
function isOidcUser(u: User): boolean {
return !!u.auth_provider && u.auth_provider !== 'local';
}
/** Used-quota percentage (0 when unlimited) for the per-user progress bar. */
function quotaPct(u: User): number {
return u.storage_quota_bytes > 0 ? (u.storage_used_bytes / u.storage_quota_bytes) * 100 : 0;
}
2026-06-17 17:06:30 -06:00
async function toggleRole(u: User) {
2026-06-17 22:07:18 -06:00
if (isSelf(u)) return;
2026-06-17 17:06:30 -06:00
const role = u.role === 'admin' ? 'user' : 'admin';
2026-06-17 22:07:18 -06:00
if (!(await showConfirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?')))) return;
2026-06-17 17:06:30 -06:00
try {
await setUserRole(u.id, role);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function toggleActive(u: User) {
2026-06-17 22:07:18 -06:00
if (isSelf(u) && u.active) return;
const msg = u.active
? t('admin.confirm_deactivate', 'Deactivate this user?')
: t('admin.confirm_activate', 'Activate this user?');
if (!(await showConfirm(msg))) return;
2026-06-17 17:06:30 -06:00
try {
await setUserActive(u.id, !u.active);
await loadUsers();
} catch (e) {
reportError(e);
}
}
2026-06-17 22:07:18 -06:00
function openQuota(u: User) {
quotaModal = {
userId: u.id,
username: u.username || u.email,
value:
u.storage_quota_bytes > 0 ? Math.round((u.storage_quota_bytes / 1024 ** 3) * 10) / 10 : 0,
unit: 1024 ** 3
};
}
async function saveQuota() {
if (!quotaModal) return;
2026-06-17 17:06:30 -06:00
try {
2026-06-17 22:07:18 -06:00
await setUserQuota(quotaModal.userId, Math.round(quotaModal.value * quotaModal.unit));
quotaModal = null;
2026-06-17 17:06:30 -06:00
await loadUsers();
} catch (e) {
reportError(e);
}
}
2026-06-17 22:07:18 -06:00
function openReset(u: User) {
resetModal = { userId: u.id, username: u.username || u.email };
resetPassword = '';
resetError = null;
}
async function submitReset(e: SubmitEvent) {
e.preventDefault();
if (!resetModal) return;
if (resetPassword.length < 8) {
resetError = t('admin.error_password_short', 'Password must be at least 8 characters.');
return;
}
resetting = true;
resetError = null;
2026-06-17 17:06:30 -06:00
try {
2026-06-17 22:07:18 -06:00
await resetUserPassword(resetModal.userId, resetPassword);
resetModal = null;
2026-06-17 17:06:30 -06:00
ui.notify(t('admin.password_reset', 'Password reset'), 'success');
2026-06-17 22:07:18 -06:00
} catch (err) {
2026-06-17 22:30:56 -06:00
resetError = errorMessage(err);
2026-06-17 22:07:18 -06:00
} finally {
resetting = false;
2026-06-17 17:06:30 -06:00
}
}
async function removeUser(u: User) {
2026-06-17 22:07:18 -06:00
if (isSelf(u)) return;
if (
!(await showConfirm(
t('admin.confirm_delete_user', { name: u.username || u.email }, 'Delete user {{name}}?')
))
)
2026-06-17 17:06:30 -06:00
return;
try {
await deleteUser(u.id);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function submitCreate(e: SubmitEvent) {
e.preventDefault();
2026-06-17 22:07:18 -06:00
const username = newUser.username.trim();
const email = newUser.email.trim();
if (username.length < 3) {
createError = t('admin.error_username_short', 'Username must be at least 3 characters.');
return;
}
if (newUser.password.length < 8) {
createError = t('admin.error_password_short', 'Password must be at least 8 characters.');
return;
}
creating = true;
createError = null;
2026-06-17 17:06:30 -06:00
try {
await createUser({
2026-06-17 22:07:18 -06:00
username,
// Email is optional — the backend auto-generates one when blank.
email: email || null,
2026-06-17 17:06:30 -06:00
password: newUser.password,
role: newUser.role,
2026-06-17 22:07:18 -06:00
quota_bytes: Math.round(newUser.quotaValue * newUser.quotaUnit)
2026-06-17 17:06:30 -06:00
});
createOpen = false;
2026-06-17 22:07:18 -06:00
newUser = {
username: '',
email: '',
password: '',
role: 'user',
quotaValue: 5,
quotaUnit: 1024 ** 3
};
2026-06-17 17:06:30 -06:00
await loadUsers();
} catch (err) {
2026-06-17 22:30:56 -06:00
createError = errorMessage(err);
2026-06-17 22:07:18 -06:00
} finally {
creating = false;
2026-06-17 17:06:30 -06:00
}
}
async function togglePlugin(p: PluginInfo) {
try {
await setPluginEnabled(p.id, !p.enabled);
await loadPlugins();
} catch (e) {
reportError(e);
}
}
async function removePlugin(p: PluginInfo) {
2026-06-17 22:07:18 -06:00
if (
!(await showConfirm(
t('admin.confirm_delete_plugin', { name: p.name }, 'Delete plugin {{name}}?')
))
)
2026-06-17 17:06:30 -06:00
return;
try {
await deletePlugin(p.id);
await loadPlugins();
} catch (e) {
reportError(e);
}
}
function changePage(delta: number) {
const next = pageIndex + delta;
if (next < 0 || next * PAGE_SIZE >= total) return;
pageIndex = next;
void loadUsers();
}
2026-06-17 22:07:18 -06:00
// Lazy-load each tab's data on first visit.
let loaded = $state<Record<Tab, boolean>>({
dashboard: false,
users: false,
plugins: false,
oidc: false,
storage: false,
smtp: false
});
$effect(() => {
if (loaded[tab]) return;
loaded[tab] = true;
if (tab === 'dashboard') void loadDashboard();
else if (tab === 'users') void loadUsers();
else if (tab === 'plugins') void loadPlugins();
else if (tab === 'oidc') void loadOidc();
else if (tab === 'storage') {
void loadStorage();
void loadMigration();
} else if (tab === 'smtp') void loadSmtp();
});
// Stop polling when leaving the storage tab / unmounting.
$effect(() => {
if (tab !== 'storage') stopMigrationPoll();
return () => stopMigrationPoll();
});
// Tear down the live log stream when leaving plugins / unmounting.
$effect(() => {
if (tab !== 'plugins') stopLogStream();
return () => stopLogStream();
2026-06-17 17:06:30 -06:00
});
</script>
<svelte:head><title>{t('admin.title', 'Admin')} · OxiCloud</title></svelte:head>
2026-06-17 22:07:18 -06:00
{#snippet envBadge(on: boolean)}
{#if on}
<span class="badge badge--env" title={t('admin.env_locked', 'Set by an environment variable')}
>ENV</span
>
{/if}
{/snippet}
2026-06-17 17:06:30 -06:00
<main class="admin">
<h1>{t('admin.title', 'Admin')}</h1>
<div class="tabs" role="tablist">
2026-06-17 22:07:18 -06:00
<button role="tab" aria-selected={tab === 'dashboard'} onclick={() => (tab = 'dashboard')}>
<Icon name="chart-pie" />
{t('admin.dashboard', 'Dashboard')}
</button>
2026-06-17 17:06:30 -06:00
<button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>
2026-06-17 22:07:18 -06:00
<Icon name="users" />
2026-06-17 17:06:30 -06:00
{t('admin.users', 'Users')}
</button>
2026-06-17 22:07:18 -06:00
<button role="tab" aria-selected={tab === 'oidc'} onclick={() => (tab = 'oidc')}>
<Icon name="key" />
{t('admin.oidc', 'OIDC / SSO')}
</button>
<button role="tab" aria-selected={tab === 'storage'} onclick={() => (tab = 'storage')}>
<Icon name="database" />
{t('admin.storage_tab', 'Storage')}
</button>
<button role="tab" aria-selected={tab === 'smtp'} onclick={() => (tab = 'smtp')}>
<Icon name="envelope" />
{t('admin.smtp', 'Email (SMTP)')}
</button>
2026-06-17 17:06:30 -06:00
<button role="tab" aria-selected={tab === 'plugins'} onclick={() => (tab = 'plugins')}>
2026-06-17 22:07:18 -06:00
<Icon name="layer-group" />
2026-06-17 17:06:30 -06:00
{t('admin.plugins', 'Plugins')}
</button>
</div>
2026-06-17 22:07:18 -06:00
{#if tab === 'dashboard'}
{#if dashboardError}
<p class="status status--error">{dashboardError}</p>
{:else if !dashboard}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else}
<div class="ds-grid">
<div class="ds-card">
<span class="ds-num">{dashboard.total_users}</span>{t('admin.total_users', 'Total users')}
</div>
<div class="ds-card">
<span class="ds-num">{dashboard.active_users}</span>{t('admin.active_users', 'Active')}
</div>
<div class="ds-card">
<span class="ds-num">{dashboard.admin_users}</span>{t('admin.admin_users', 'Admins')}
</div>
<div class="ds-card">
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
</div>
</div>
<div class="ds-grid">
<div class="ds-card">
<span class="ds-flag" class:ds-flag--on={dashboard.auth_enabled}>
{dashboard.auth_enabled
? t('admin.enabled', 'Enabled')
: t('admin.disabled', 'Disabled')}
</span>
{t('admin.auth', 'Authentication')}
</div>
<div class="ds-card">
<span class="ds-flag" class:ds-flag--on={dashboard.oidc_configured}>
{dashboard.oidc_configured ? t('admin.active', 'Active') : t('admin.off', 'Off')}
</span>
{t('admin.oidc', 'OIDC / SSO')}
</div>
<div class="ds-card">
<span class="ds-flag" class:ds-flag--on={dashboard.quotas_enabled}>
{dashboard.quotas_enabled
? t('admin.enabled', 'Enabled')
: t('admin.disabled', 'Disabled')}
</span>
{t('admin.quotas', 'Quotas')}
</div>
</div>
{#if dashboard.users_over_quota > 0}
<div class="card warn-card warn-card--danger">
<Icon name="exclamation-circle" />
<div>
<strong class="ds-num">{dashboard.users_over_quota}</strong>
{t('admin.over_quota', { n: dashboard.users_over_quota }, '{{n}} users over quota')}
</div>
</div>
{/if}
{#if dashboard.users_over_80_percent > 0}
<div class="card warn-card warn-card--warn">
<Icon name="exclamation-triangle" />
<div>
<strong class="ds-num">{dashboard.users_over_80_percent}</strong>
{t(
'admin.over_80',
{ n: dashboard.users_over_80_percent },
'{{n}} users over 80% quota'
)}
</div>
</div>
{/if}
<div class="card">
<h2>{t('admin.storage', 'Storage')}</h2>
<div class="ds-bar">
<div
class="ds-fill"
class:ds-fill--warn={dashboard.storage_usage_percent > 70}
class:ds-fill--danger={dashboard.storage_usage_percent > 90}
style:width="{Math.min(dashboard.storage_usage_percent, 100)}%"
></div>
</div>
<p class="muted">
{formatBytes(dashboard.total_used_bytes)} / {formatBytes(dashboard.total_quota_bytes)}
({dashboard.storage_usage_percent.toFixed(1)}%)
</p>
</div>
{#if dashboard.registration_enabled !== undefined}
<div class="card">
<h2>{t('admin.registration', 'Registration')}</h2>
<label class="checkbox">
<input
type="checkbox"
checked={dashboard.registration_enabled}
onchange={(e) => toggleRegistration(e.currentTarget.checked)}
/>
<span>{t('admin.allow_registration', 'Allow public user registration')}</span>
</label>
{#if !dashboard.registration_enabled}
<p class="alert alert--warn registration-warning">
<Icon name="exclamation-triangle" />
{t(
'admin.registration_disabled_warning',
'Public registration is disabled. Only admins can create new accounts.'
)}
</p>
{/if}
</div>
{/if}
{/if}
{:else if tab === 'oidc'}
<div class="card">
<h2>{t('admin.oidc', 'OIDC / SSO')}</h2>
{#if !oidc}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else}
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveOidc())}>
<label class="checkbox">
<input type="checkbox" bind:checked={oidc.enabled} />
<span>{t('admin.oidc_enabled', 'Enable OIDC login')}</span>
</label>
<label
><span
>{t('admin.oidc_issuer', 'Issuer URL')}{@render envBadge(
isEnvLocked(oidc.env_overrides, 'issuer_url')
)}</span
>
<input
bind:value={oidc.issuer_url}
placeholder="https://idp.example.com"
disabled={isEnvLocked(oidc.env_overrides, 'issuer_url')}
/></label
>
<button type="button" class="btn btn-secondary" onclick={runOidcTest}>
<Icon name="search" />
{t('admin.oidc_discover', 'Test / discover')}
</button>
{#if oidcTest}
<div
class="discovery-result {oidcTest.success
? 'discovery-result--ok'
: 'discovery-result--fail'}"
>
<strong>
<Icon name={oidcTest.success ? 'check-circle' : 'times-circle'} />
{oidcTest.message}
</strong>
{#if oidcTest.success}
<dl class="kv">
<dt>{t('admin.oidc_issuer', 'Issuer URL')}</dt>
<dd>{oidcTest.issuer || '—'}</dd>
<dt>{t('admin.oidc_auth_endpoint', 'Auth endpoint')}</dt>
<dd>{oidcTest.authorization_endpoint || '—'}</dd>
</dl>
{/if}
</div>
{/if}
<label
><span
>{t('admin.oidc_client_id', 'Client ID')}{@render envBadge(
isEnvLocked(oidc.env_overrides, 'client_id')
)}</span
>
<input
bind:value={oidc.client_id}
disabled={isEnvLocked(oidc.env_overrides, 'client_id')}
/></label
>
<label
><span
>{t('admin.oidc_client_secret', 'Client secret')}{@render envBadge(
isEnvLocked(oidc.env_overrides, 'client_secret')
)}</span
>
<input
type="password"
bind:value={oidc.client_secret}
disabled={isEnvLocked(oidc.env_overrides, 'client_secret')}
placeholder={oidc.client_secret_set
? t('admin.unchanged', 'Leave blank to keep current')
: ''}
/>
{#if oidc.client_secret_set}
<span class="secret-hint">
<Icon name="check-circle" />
{t('admin.oidc_secret_set', 'A client secret is already configured.')}
</span>
{/if}</label
>
<label
><span
>{t('admin.oidc_scopes', 'Scopes')}{@render envBadge(
isEnvLocked(oidc.env_overrides, 'scopes')
)}</span
>
<input
bind:value={oidc.scopes}
placeholder="openid profile email"
disabled={isEnvLocked(oidc.env_overrides, 'scopes')}
/></label
>
<label
><span
>{t('admin.oidc_provider_name', 'Provider name')}{@render envBadge(
isEnvLocked(oidc.env_overrides, 'provider_name')
)}</span
>
<input
bind:value={oidc.provider_name}
disabled={isEnvLocked(oidc.env_overrides, 'provider_name')}
/></label
>
<label
><span
>{t('admin.oidc_admin_groups', 'Admin groups')}{@render envBadge(
isEnvLocked(oidc.env_overrides, 'admin_groups')
)}</span
>
<input
bind:value={oidc.admin_groups}
disabled={isEnvLocked(oidc.env_overrides, 'admin_groups')}
/></label
>
<label class="checkbox">
<input type="checkbox" bind:checked={oidc.auto_provision} />
<span>{t('admin.oidc_auto_provision', 'Auto-provision users on first login')}</span>
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={oidc.disable_password_login} />
<span>{t('admin.oidc_disable_pw', 'Disable password login (OIDC only)')}</span>
</label>
{#if oidc.callback_url}
<p class="muted callback-row">
{t('admin.oidc_callback', 'Callback URL')}: <code>{oidc.callback_url}</code>
<button
type="button"
class="btn btn-sm btn-secondary"
onclick={() => copyText(oidc?.callback_url ?? '')}
>
<Icon name="copy" />
{t('common.copy', 'Copy')}
</button>
</p>
{/if}
{#if oidcMsg}<p class={oidcMsg.ok ? 'status--ok' : 'status--error'}>
{oidcMsg.text}
</p>{/if}
<button class="btn btn-primary" type="submit" disabled={oidcSaving}>
{t('common.save', 'Save')}
</button>
</form>
{/if}
</div>
{:else if tab === 'storage'}
<div class="card">
<h2>{t('admin.storage_tab', 'Storage')}</h2>
{#if !storage}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else}
<form class="form" onsubmit={(e) => (e.preventDefault(), doSaveStorage())}>
<label
><span>{t('admin.storage_backend', 'Backend')}</span>
<select bind:value={sForm.backend}>
<option value="local">local</option>
<option value="s3">S3</option>
</select></label
>
{#if sForm.backend === 's3'}
<label
><span>{t('admin.storage_preset', 'Preset')}</span>
<select bind:value={sForm.preset} onchange={applyPreset}>
{#each Object.keys(STORAGE_PRESETS) as p (p)}<option value={p}>{p}</option>{/each}
</select></label
>
<label
><span
>{t('admin.storage_endpoint', 'Endpoint URL')}{@render envBadge(
isEnvLocked(storage.env_overrides, 's3_endpoint_url')
)}</span
>
<input
bind:value={sForm.endpoint}
disabled={isEnvLocked(storage.env_overrides, 's3_endpoint_url')}
/></label
>
<label
><span
>{t('admin.storage_bucket', 'Bucket')}{@render envBadge(
isEnvLocked(storage.env_overrides, 's3_bucket')
)}</span
>
<input
bind:value={sForm.bucket}
disabled={isEnvLocked(storage.env_overrides, 's3_bucket')}
/></label
>
<label
><span
>{t('admin.storage_region', 'Region')}{@render envBadge(
isEnvLocked(storage.env_overrides, 's3_region')
)}</span
>
<input
bind:value={sForm.region}
disabled={isEnvLocked(storage.env_overrides, 's3_region')}
/></label
>
<label
><span
>{t('admin.storage_access_key', 'Access key')}{@render envBadge(
isEnvLocked(storage.env_overrides, 's3_access_key')
)}</span
>
<input
bind:value={sForm.accessKey}
disabled={isEnvLocked(storage.env_overrides, 's3_access_key')}
placeholder={storage.s3_access_key_set
? t('admin.unchanged', 'Leave blank to keep current')
: ''}
/></label
>
<label
><span
>{t('admin.storage_secret_key', 'Secret key')}{@render envBadge(
isEnvLocked(storage.env_overrides, 's3_secret_key')
)}</span
>
<input
type="password"
bind:value={sForm.secretKey}
disabled={isEnvLocked(storage.env_overrides, 's3_secret_key')}
placeholder={storage.s3_secret_key_set
? t('admin.unchanged', 'Leave blank to keep current')
: ''}
/></label
>
<label class="checkbox">
<input type="checkbox" bind:checked={sForm.pathStyle} />
<span>{t('admin.storage_path_style', 'Force path-style URLs')}</span>
</label>
{/if}
{#if storageMsg}<p class={storageMsg.ok ? 'status--ok' : 'status--error'}>
{storageMsg.text}
</p>{/if}
<div class="smtp-test">
<button class="btn btn-primary" type="submit" disabled={storageBusy}
>{t('common.save', 'Save')}</button
>
{#if sForm.backend === 's3'}
<button
type="button"
class="btn btn-secondary"
disabled={storageBusy}
onclick={doTestStorage}
>
{t('admin.storage_test', 'Test connection')}
</button>
{/if}
</div>
</form>
<dl class="kv">
<dt>{t('admin.storage_current', 'Current backend')}</dt>
<dd>{storage.current_backend ?? '—'}</dd>
<dt>{t('admin.storage_blobs', 'Blobs')}</dt>
<dd>{storage.total_blobs ?? '—'}</dd>
<dt>{t('admin.storage_size', 'Stored')}</dt>
<dd>
{storage.total_bytes_stored != null ? formatBytes(storage.total_bytes_stored) : '—'}
</dd>
<dt>{t('admin.storage_dedup', 'Dedup ratio')}</dt>
<dd>{storage.dedup_ratio != null ? `${storage.dedup_ratio.toFixed(2)}x` : '—'}</dd>
</dl>
{/if}
</div>
<div class="card">
<h2>{t('admin.migration', 'Storage migration')}</h2>
{#if !migration}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else}
<p class="muted">{t('admin.status', 'Status')}: <strong>{migration.status}</strong></p>
{#if migration.total_blobs > 0}
<div class="ds-bar">
<div class="ds-fill" style:width="{migrationPct}%"></div>
</div>
<p class="muted">
{migration.migrated_blobs} / {migration.total_blobs} ({migrationPct}%) ·
{formatBytes(migration.migrated_bytes)}
{#if migration.throughput_bytes_per_sec && migration.status === 'running'}
· {formatBytes(Math.round(migration.throughput_bytes_per_sec))}/s
{/if}
{#if migrationEtaMin != null}
· {t('admin.mig_eta', { min: migrationEtaMin }, `~${migrationEtaMin} min remaining`)}
{/if}
</p>
{/if}
{#if migration.failed_blobs && migration.failed_blobs.length > 0}
<details class="mig-failed">
<summary>
{t(
'admin.mig_failed',
{ n: migration.failed_blobs.length },
`${migration.failed_blobs.length} failed blobs`
)}
</summary>
<pre class="mig-failed__list">{migration.failed_blobs.join('\n')}</pre>
</details>
{/if}
<div class="smtp-test">
<!-- Start: only when no migration is active (running/paused) or completed. -->
{#if migration.status !== 'running' && migration.status !== 'paused' && migration.status !== 'completed'}
<button class="btn btn-primary" onclick={() => doMigration('start')}
>{t('admin.mig_start', 'Start')}</button
>
{/if}
{#if migration.status === 'running'}
<button class="btn btn-secondary" onclick={() => doMigration('pause')}
>{t('admin.mig_pause', 'Pause')}</button
>
{/if}
{#if migration.status === 'paused'}
<button class="btn btn-primary" onclick={() => doMigration('resume')}
>{t('admin.mig_resume', 'Resume')}</button
>
{/if}
<!-- Verify + Finalize: only once the copy phase has completed. -->
{#if migration.status === 'completed'}
<button class="btn btn-secondary" disabled={verifying} onclick={doVerify}>
<Icon name="check-double" />
{verifying
? t('admin.mig_verifying', 'Verifying…')
: t('admin.mig_verify', 'Verify integrity')}
</button>
<button class="btn btn-secondary" onclick={() => doMigration('complete')}
>{t('admin.mig_complete', 'Finalize')}</button
>
{/if}
</div>
{#if verifyError}
<div class="discovery-result discovery-result--fail">
<strong><Icon name="times-circle" /> {verifyError}</strong>
</div>
{:else if verifyResult}
<div
class="discovery-result {verifyResult.passed
? 'discovery-result--ok'
: 'discovery-result--fail'}"
>
<strong>
<Icon name={verifyResult.passed ? 'check-circle' : 'times-circle'} />
{verifyResult.passed
? t('admin.mig_verify_passed', 'Verification passed')
: t('admin.mig_verify_failed', 'Verification failed')}
</strong>
{#if verifyResult.passed}
<p class="muted">
{t(
'admin.mig_verify_summary',
{ checked: verifyResult.sample_checked, total: verifyResult.pg_blob_count },
'{{checked}} blobs checked, {{total}} total in database'
)}
</p>
{:else}
<p class="muted">
{[
verifyResult.missing_in_target.length
? t(
'admin.mig_verify_missing',
{ n: verifyResult.missing_in_target.length },
'{{n}} missing'
)
: '',
verifyResult.size_mismatches.length
? t(
'admin.mig_verify_mismatch',
{ n: verifyResult.size_mismatches.length },
'{{n}} size mismatches'
)
: ''
]
.filter(Boolean)
.join(', ')}
</p>
{/if}
</div>
{/if}
{/if}
</div>
{:else if tab === 'smtp'}
<div class="card">
<h2>{t('admin.smtp_status', 'SMTP status')}</h2>
{#if !smtp}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else}
<dl class="kv">
<dt>{t('admin.smtp_enabled', 'Enabled')}</dt>
<dd>{smtp.enabled ? t('common.yes', 'Yes') : t('common.no', 'No')}</dd>
<dt>{t('admin.smtp_host', 'Host')}</dt>
<dd>{smtp.host || '—'}</dd>
<dt>{t('admin.smtp_port', 'Port')}</dt>
<dd>{smtp.port || '—'}</dd>
<dt>TLS</dt>
<dd>{smtp.tls || '—'}</dd>
<dt>{t('admin.smtp_from', 'From')}</dt>
<dd>{smtp.from || '—'}</dd>
<dt>{t('admin.smtp_user_state', 'Auth')}</dt>
<dd>{smtp.user_state || '—'}</dd>
</dl>
{/if}
</div>
<div class="card">
<h2>{t('admin.smtp_test', 'Send test email')}</h2>
<div class="smtp-test">
<input
type="email"
bind:value={smtpTo}
placeholder={t('admin.smtp_to', 'recipient@example.com')}
/>
<button class="btn btn-primary" disabled={smtpSending} onclick={runSmtpTest}>
<Icon name="paper-plane" />
{smtpSending ? t('admin.smtp_sending', 'Sending…') : t('admin.smtp_send', 'Send')}
</button>
</div>
{#if smtpResult}
{#if smtpResult.success}
<p class="status--ok">
<strong>{t('admin.smtp_sent', 'Test email sent.')}</strong><br />
{t('admin.smtp_server_code', 'Server replied')}:
<code>{smtpResult.code ?? ''} {smtpResult.message ?? ''}</code>
</p>
{:else}
<p class="status--error">
<strong>{t('admin.smtp_fail', 'Send failed.')}</strong><br />
<code
>{smtpResult.error || smtpResult.message || t('common.error', 'unknown error')}</code
>
</p>
{/if}
{/if}
</div>
{:else if tab === 'users'}
2026-06-17 17:06:30 -06:00
<div class="bar">
<button class="btn btn--primary" onclick={() => (createOpen = true)}>
2026-06-17 22:07:18 -06:00
<Icon name="user-plus" />
2026-06-17 17:06:30 -06:00
{t('admin.create_user', 'Create user')}
</button>
</div>
{#if usersError}
<p class="status status--error">{usersError}</p>
{:else}
<table class="table">
<thead>
<tr>
<th>{t('admin.user', 'User')}</th>
<th>{t('admin.role', 'Role')}</th>
2026-06-17 22:07:18 -06:00
<th>{t('admin.auth', 'Auth')}</th>
2026-06-17 17:06:30 -06:00
<th>{t('admin.status', 'Status')}</th>
2026-06-17 22:07:18 -06:00
<th>{t('admin.quota', 'Storage usage')}</th>
<th>{t('admin.last_login', 'Last login')}</th>
2026-06-17 17:06:30 -06:00
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
2026-06-17 22:07:18 -06:00
{@const pct = quotaPct(u)}
2026-06-17 17:06:30 -06:00
<tr>
<td>
<div class="user-cell">
2026-06-17 22:07:18 -06:00
<strong>
{u.username || u.email}
{#if isSelf(u)}
<span class="badge badge--self">{t('admin.you_badge', 'you')}</span>
{/if}
</strong>
2026-06-17 17:06:30 -06:00
<span class="muted">{u.email}</span>
</div>
</td>
<td>
2026-06-17 22:07:18 -06:00
<span class="badge badge--{u.role === 'admin' ? 'admin' : 'user'}">
{#if u.role === 'admin'}<Icon name="shield-alt" />{/if}
{u.role}
</span>
</td>
<td>
{#if isOidcUser(u)}
<span class="badge badge--oidc" title={u.auth_provider}>
<Icon name="key" />
{u.auth_provider}
</span>
{:else}
<span class="badge badge--local">{t('admin.local', 'local')}</span>
{/if}
2026-06-17 17:06:30 -06:00
</td>
2026-06-17 22:07:18 -06:00
<td>
<span class="badge badge--{u.active ? 'active' : 'inactive'}">
{u.active ? t('admin.active', 'Active') : t('admin.inactive', 'Inactive')}
</span>
</td>
<td>
<div class="quota-cell">
<div class="quota-bar">
<div
class="quota-fill"
class:quota-fill--warn={pct > 70}
class:quota-fill--danger={pct > 90}
style:width="{Math.min(pct, 100)}%"
></div>
</div>
<span class="muted">
{formatBytes(u.storage_used_bytes)} / {u.storage_quota_bytes > 0
? formatBytes(u.storage_quota_bytes)
: '∞'}
</span>
</div>
</td>
<td class="muted">{timeAgo(u.last_login_at)}</td>
2026-06-17 17:06:30 -06:00
<td class="actions">
2026-06-17 22:07:18 -06:00
<button
class="icon-btn"
title={t('admin.edit_quota_title', 'Edit quota')}
aria-label={t('admin.edit_quota_title', 'Edit quota')}
onclick={() => openQuota(u)}
>
<Icon name="box" />
</button>
{#if !isOidcUser(u)}
<button
class="icon-btn"
title={t('admin.reset_password_title', 'Reset password')}
aria-label={t('admin.reset_password_title', 'Reset password')}
onclick={() => openReset(u)}
>
<Icon name="key" />
</button>
{/if}
<button
class="icon-btn"
title={t('admin.toggle_role_title', 'Toggle admin role')}
aria-label={t('admin.toggle_role_title', 'Toggle admin role')}
disabled={isSelf(u)}
onclick={() => toggleRole(u)}
2026-06-17 17:06:30 -06:00
>
2026-06-17 22:07:18 -06:00
<Icon name={u.role === 'admin' ? 'user' : 'crown'} />
2026-06-17 17:06:30 -06:00
</button>
2026-06-17 22:07:18 -06:00
<button
class="icon-btn {u.active ? 'icon-btn--danger' : 'icon-btn--success'}"
title={u.active
? t('admin.deactivate_title', 'Deactivate')
: t('admin.activate_title', 'Activate')}
aria-label={u.active
? t('admin.deactivate_title', 'Deactivate')
: t('admin.activate_title', 'Activate')}
disabled={isSelf(u) && u.active}
onclick={() => toggleActive(u)}
2026-06-17 17:06:30 -06:00
>
2026-06-17 22:07:18 -06:00
<Icon name={u.active ? 'ban' : 'check'} />
</button>
<button
class="icon-btn icon-btn--danger"
title={t('admin.delete_title', 'Delete user')}
aria-label={t('admin.delete_title', 'Delete user')}
disabled={isSelf(u)}
onclick={() => removeUser(u)}
2026-06-17 17:06:30 -06:00
>
2026-06-17 22:07:18 -06:00
<Icon name="trash-alt" />
2026-06-17 17:06:30 -06:00
</button>
</td>
</tr>
{/each}
</tbody>
</table>
<div class="pager">
<button class="btn" disabled={pageIndex === 0} onclick={() => changePage(-1)}>‹</button>
<span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span>
<button
class="btn"
disabled={(pageIndex + 1) * PAGE_SIZE >= total}
onclick={() => changePage(1)}>›</button
>
</div>
{/if}
{:else if !pluginsAvailable}
<p class="status">{t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}</p>
{:else if pluginsError}
<p class="status status--error">{pluginsError}</p>
{:else}
2026-06-17 22:07:18 -06:00
<div class="install-bar">
<div>
<strong>{t('admin.plugins_install', 'Install plugin')}</strong>
<span class="muted"
>{t('admin.plugins_install_hint', 'Upload a plugin bundle (.zip).')}</span
>
</div>
<label class="btn btn-primary" class:disabled={installing}>
<Icon name="cloud-upload-alt" />
{installing
? t('admin.plugins_installing', 'Installing…')
: t('admin.plugins_upload', 'Upload .zip')}
<input
type="file"
accept=".zip,application/zip"
hidden
disabled={installing}
onchange={onInstallPlugin}
/>
</label>
</div>
{#if installMsg}
<p class={installMsg.ok ? 'status--ok' : 'status--error'}>{installMsg.text}</p>
{/if}
{#if plugins.length === 0}
<p class="status">{t('admin.no_plugins', 'No plugins installed.')}</p>
{:else}
<table class="table">
<thead>
2026-06-17 17:06:30 -06:00
<tr>
2026-06-17 22:07:18 -06:00
<th>{t('admin.plugin', 'Plugin')}</th>
<th>{t('admin.plugins_col_id', 'ID')}</th>
<th>{t('admin.version', 'Version')}</th>
<th>{t('admin.plugins_col_events', 'Events')}</th>
<th>{t('admin.status', 'Status')}</th>
<th></th>
2026-06-17 17:06:30 -06:00
</tr>
2026-06-17 22:07:18 -06:00
</thead>
<tbody>
{#each plugins as p (p.id)}
<tr>
<td>
<div class="user-cell">
<strong>{p.name}</strong>
{#if p.description}<span class="muted">{p.description}</span>{/if}
</div>
</td>
<td><code>{p.id}</code></td>
<td>{p.version ?? '—'}</td>
<td>
{#if p.subscriptions && p.subscriptions.length > 0}
<span class="events">{p.subscriptions.length}</span>
{:else}
—
{/if}
</td>
<td>
<span class="badge badge--{p.enabled ? 'active' : 'inactive'}">
{p.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')}
</span>
</td>
<td class="actions">
<button
class="icon-btn"
title={t('admin.plugins_details', 'Logs & details')}
aria-label={t('admin.plugins_details', 'Logs & details')}
onclick={() => openLogs(p)}
>
<Icon name="list" />
</button>
<button
class="icon-btn {p.enabled ? '' : 'icon-btn--success'}"
title={p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')}
aria-label={p.enabled
? t('admin.disable', 'Disable')
: t('admin.enable', 'Enable')}
onclick={() => togglePlugin(p)}
>
<Icon name={p.enabled ? 'pause' : 'play'} />
</button>
<button
class="icon-btn icon-btn--danger"
title={t('common.delete', 'Delete')}
aria-label={t('common.delete', 'Delete')}
onclick={() => removePlugin(p)}
>
<Icon name="trash-alt" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
2026-06-17 17:06:30 -06:00
{/if}
</main>
<Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}>
<form id="create-user-form" onsubmit={submitCreate} class="form">
<label
><span>{t('admin.username', 'Username')}</span>
2026-06-17 22:07:18 -06:00
<input bind:value={newUser.username} minlength="3" required /></label
2026-06-17 17:06:30 -06:00
>
<label
2026-06-17 22:07:18 -06:00
><span
>{t('admin.email', 'Email')}
<span class="muted">({t('common.optional', 'optional')})</span></span
>
<input
type="email"
bind:value={newUser.email}
placeholder={t('admin.email_auto', 'Auto-generated if left blank')}
/></label
2026-06-17 17:06:30 -06:00
>
<label
><span>{t('admin.password', 'Password')}</span>
2026-06-17 22:07:18 -06:00
<input type="password" bind:value={newUser.password} minlength="8" required /></label
2026-06-17 17:06:30 -06:00
>
<label
><span>{t('admin.role', 'Role')}</span>
<select bind:value={newUser.role}>
<option value="user">user</option>
<option value="admin">admin</option>
</select></label
>
<label
2026-06-17 22:07:18 -06:00
><span>{t('admin.quota', 'Quota')}</span>
<div class="quota-input">
<input type="number" min="0" step="0.1" bind:value={newUser.quotaValue} />
<select bind:value={newUser.quotaUnit}>
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
>{/each}
</select>
</div>
<span class="muted">{t('admin.quota_unlimited_hint', '0 = unlimited')}</span></label
2026-06-17 17:06:30 -06:00
>
2026-06-17 22:07:18 -06:00
{#if createError}<p class="status--error">{createError}</p>{/if}
2026-06-17 17:06:30 -06:00
</form>
{#snippet footer()}
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button>
2026-06-17 22:07:18 -06:00
<button class="btn btn--primary" type="submit" form="create-user-form" disabled={creating}>
{creating ? t('admin.creating', 'Creating…') : t('common.create', 'Create')}
</button>
{/snippet}
</Modal>
<!-- Quota edit modal -->
<Modal
open={quotaModal !== null}
title={t('admin.edit_quota_title', 'Edit quota')}
onclose={() => (quotaModal = null)}
>
{#if quotaModal}
<form
id="quota-form"
class="form"
onsubmit={(e) => {
e.preventDefault();
void saveQuota();
}}
>
<p class="muted">
{t('admin.quota_for', 'Quota for')} <strong>{quotaModal.username}</strong>
</p>
<label
><span>{t('admin.quota', 'Quota')}</span>
<div class="quota-input">
<input type="number" min="0" step="0.1" bind:value={quotaModal.value} />
<select bind:value={quotaModal.unit}>
{#each QUOTA_UNITS as unit (unit.label)}<option value={unit.value}>{unit.label}</option
>{/each}
</select>
</div>
<span class="muted">{t('admin.quota_unlimited_hint', '0 = unlimited')}</span></label
>
</form>
{/if}
{#snippet footer()}
<button class="btn" onclick={() => (quotaModal = null)}>{t('common.cancel', 'Cancel')}</button>
<button class="btn btn--primary" type="submit" form="quota-form">
{t('common.save', 'Save')}
</button>
{/snippet}
</Modal>
<!-- Reset-password modal -->
<Modal
open={resetModal !== null}
title={t('admin.reset_password_title', 'Reset password')}
onclose={() => (resetModal = null)}
>
{#if resetModal}
<form id="reset-pw-form" class="form" onsubmit={submitReset}>
<p class="muted">
{t('admin.reset_pw_for', 'New password for')} <strong>{resetModal.username}</strong>
</p>
<label
><span>{t('admin.new_password', 'New password')}</span>
<input type="password" bind:value={resetPassword} minlength="8" required /></label
>
{#if resetError}<p class="status--error">{resetError}</p>{/if}
</form>
{/if}
{#snippet footer()}
<button class="btn" onclick={() => (resetModal = null)}>{t('common.cancel', 'Cancel')}</button>
<button class="btn btn--primary" type="submit" form="reset-pw-form" disabled={resetting}>
{resetting ? t('admin.resetting', 'Resetting…') : t('admin.reset_btn', 'Reset')}
</button>
{/snippet}
</Modal>
<!-- Styled confirm modal (replaces native confirm) -->
<Modal
open={confirmState !== null}
title={t('common.confirm', 'Confirm')}
onclose={() => resolveConfirm(false)}
>
<p>{confirmState?.message}</p>
{#snippet footer()}
<button class="btn" onclick={() => resolveConfirm(false)}>{t('common.cancel', 'Cancel')}</button
>
<button class="btn btn--primary" onclick={() => resolveConfirm(true)}>
{t('common.confirm', 'Confirm')}
</button>
{/snippet}
</Modal>
<Modal
open={logsPlugin !== null}
title={logsPlugin?.name ?? t('admin.plugin_logs', 'Plugin logs')}
onclose={closeLogs}
>
{#if logsPlugin}
<dl class="kv plugin-meta">
<dt>{t('admin.plugins_col_id', 'ID')}</dt>
<dd><code>{logsPlugin.id}</code></dd>
<dt>{t('admin.version', 'Version')}</dt>
<dd>{logsPlugin.version ?? '—'}</dd>
{#if logsPlugin.abi != null}
<dt>ABI</dt>
<dd>{logsPlugin.abi}</dd>
{/if}
<dt>{t('admin.plugins_col_events', 'Events')}</dt>
<dd>
{#if logsPlugin.subscriptions && logsPlugin.subscriptions.length > 0}
{#each logsPlugin.subscriptions as ev (ev)}<code class="event-tag">{ev}</code>
{/each}
{:else}
—
{/if}
</dd>
<dt>{t('admin.status', 'Status')}</dt>
<dd>
<span class="badge badge--{logsPlugin.enabled ? 'active' : 'inactive'}">
{logsPlugin.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')}
</span>
</dd>
</dl>
{/if}
{#if retention}
<form class="form retention-form" onsubmit={(e) => (e.preventDefault(), saveRetention())}>
<h3>{t('admin.plugins_retention', 'Log retention')}</h3>
<label
><span>{t('admin.plugins_retention_days', 'Keep for (days)')}</span>
<input type="number" min="0" bind:value={retentionDays} /></label
>
<label
><span>{t('admin.plugins_retention_max', 'Max size (MB)')}</span>
<input type="number" min="0" bind:value={retentionMb} /></label
>
{#if retentionMsg}<p class="muted">{retentionMsg}</p>{/if}
<button class="btn btn-secondary" type="submit"
>{t('admin.plugins_retention_save', 'Save retention')}</button
>
</form>
{/if}
<div class="logs-toolbar">
<select bind:value={logsLevel} onchange={reloadLogsFromStart}>
<option value="">{t('admin.logs_all', 'All levels')}</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
<input
placeholder={t('admin.logs_search', 'Search…')}
bind:value={logsSearch}
onkeydown={(e) => e.key === 'Enter' && reloadLogsFromStart()}
/>
<button class="btn btn-secondary" onclick={reloadLogsFromStart}
>{t('common.search', 'Search')}</button
>
<label class="live-toggle">
<input type="checkbox" bind:checked={logsLive} onchange={toggleLive} />
<span>{t('admin.logs_live', 'Live')}</span>
</label>
</div>
{#if logsLoading}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else if logs.length === 0}
<p class="status">{t('admin.logs_empty', 'No log entries.')}</p>
{:else}
<div class="logs-table-wrap">
<table class="table logs-table">
<thead>
<tr>
<th>{t('admin.logs_time', 'Time')}</th>
<th>{t('admin.logs_level', 'Level')}</th>
<th>{t('admin.logs_kind', 'Kind')}</th>
<th>{t('admin.logs_invocation', 'Invocation')}</th>
<th>{t('admin.logs_message', 'Message')}</th>
</tr>
</thead>
<tbody>
{#each logs as entry, i (i)}
<tr class="log-row log-row--{(entry.level ?? 'info').toLowerCase()}">
<td class="log-time">{timeAgo(entry.ts ?? entry.timestamp)}</td>
<td>
<span class="log-level log-level--{(entry.level ?? 'info').toLowerCase()}"
>{entry.level ?? 'info'}</span
>
</td>
<td><code>{logKind(entry)}</code></td>
<td><code class="log-inv">{entry.invocation_id ?? '—'}</code></td>
<td class="log-msg">{logMsg(entry)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="pager logs-pager">
<button class="btn" disabled={logsPage === 0} onclick={logsPrev}>‹</button>
<span>
{#if logsTotal === 0}
{t('admin.logs_empty', 'No log entries.')}
{:else}
{t(
'admin.logs_showing',
{
from: logsPage * LOGS_PAGE_SIZE + 1,
to: Math.min((logsPage + 1) * LOGS_PAGE_SIZE, logsTotal),
total: logsTotal
},
'Showing {{from}}–{{to}} of {{total}}'
)}
{/if}
</span>
<button class="btn" disabled={(logsPage + 1) * LOGS_PAGE_SIZE >= logsTotal} onclick={logsNext}
>›</button
>
</div>
{#snippet footer()}
<button class="btn btn-danger" onclick={purgeLogs}
>{t('admin.plugins_clear_logs', 'Clear logs')}</button
>
<button class="btn btn-secondary" onclick={closeLogs}>
{t('common.close', 'Close')}
2026-06-17 17:06:30 -06:00
</button>
{/snippet}
</Modal>
<style>
2026-06-17 22:07:18 -06:00
.logs-toolbar {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.logs-toolbar input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.live-toggle {
display: flex;
align-items: center;
gap: var(--space-1);
white-space: nowrap;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.logs-table-wrap {
max-height: 50vh;
overflow: auto;
}
.logs-table {
font-family: var(--font-mono, monospace);
font-size: var(--text-sm);
}
.log-time {
color: var(--color-text-muted);
white-space: nowrap;
}
.log-inv {
font-size: var(--text-xs, 0.7rem);
color: var(--color-text-muted);
}
.log-level {
text-transform: uppercase;
font-size: var(--text-xs, 0.7rem);
font-weight: var(--weight-semibold, 600);
}
.log-level--error {
color: var(--color-danger-text);
}
.log-level--warn {
color: var(--color-warning-text);
}
.log-msg {
word-break: break-word;
}
.logs-pager {
margin-top: var(--space-3);
}
.card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-5);
margin-bottom: var(--space-4);
}
.card h2 {
margin: 0 0 var(--space-3);
font-size: 1.125rem;
}
.checkbox {
display: flex;
align-items: center;
gap: var(--space-2);
}
.ds-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.ds-card {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-4);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.ds-num {
font-size: 1.5rem;
font-weight: var(--weight-bold);
color: var(--color-text-heading);
}
.ds-bar {
height: 8px;
background: var(--color-bg-muted);
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: var(--space-2);
}
.ds-fill {
height: 100%;
background: var(--color-success-text);
}
.ds-fill--warn {
background: var(--color-warning-text);
}
.ds-fill--danger {
background: var(--color-danger-text);
}
.kv {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-1) var(--space-4);
margin: 0;
}
.kv dt {
color: var(--color-text-muted);
}
.kv dd {
margin: 0;
}
.badge {
display: inline-block;
padding: 0.05rem 0.4rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs, 0.7rem);
font-weight: var(--weight-semibold, 600);
line-height: 1.4;
vertical-align: middle;
}
.badge--env {
margin-left: var(--space-2);
background: var(--color-warning-bg);
color: var(--color-warning-text);
}
.badge--oidc {
background: var(--color-info-bg);
color: var(--color-info-text);
text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.badge--local {
background: var(--color-bg-muted);
color: var(--color-text-muted);
text-transform: uppercase;
}
.badge--active {
background: var(--color-success-bg);
color: var(--color-success-text);
}
.badge--inactive {
background: var(--color-bg-muted);
color: var(--color-text-muted);
}
.badge--admin {
background: var(--color-info-bg);
color: var(--color-info-text);
text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.badge--user {
background: var(--color-bg-muted);
color: var(--color-text-muted);
text-transform: uppercase;
}
.badge--self {
margin-left: var(--space-1);
background: var(--color-warning-bg);
color: var(--color-warning-text);
text-transform: uppercase;
}
/* Enabled/disabled feature flag indicator on the dashboard cards. */
.ds-flag {
font-size: 1.125rem;
font-weight: var(--weight-bold);
color: var(--color-text-muted);
}
.ds-flag--on {
color: var(--color-success-text);
}
.warn-card {
display: flex;
align-items: center;
gap: var(--space-3);
}
.warn-card--warn {
border-color: var(--color-warning-text);
color: var(--color-warning-text);
}
.warn-card--danger {
border-color: var(--color-danger-text);
color: var(--color-danger-text);
}
/* Per-user storage-usage progress bar in the users table. */
.quota-cell {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 9rem;
}
.quota-bar {
height: 6px;
background: var(--color-bg-muted);
border-radius: var(--radius-full);
overflow: hidden;
}
.quota-fill {
height: 100%;
background: var(--color-success-text);
}
.quota-fill--warn {
background: var(--color-warning-text);
}
.quota-fill--danger {
background: var(--color-danger-text);
}
.quota-input {
display: flex;
gap: var(--space-2);
}
.quota-input input {
flex: 1;
}
/* Icon-only row actions with hover tooltips (title attr). */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.icon-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.icon-btn--danger {
color: var(--color-danger-text);
}
.icon-btn--success {
color: var(--color-success-text);
}
.secret-hint {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: var(--space-1);
font-size: var(--text-sm);
color: var(--color-success-text);
}
.registration-warning {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-3);
}
.alert--warn {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-warning-bg);
color: var(--color-warning-text);
}
/* Discovery / verify result panels. */
.discovery-result {
margin-top: var(--space-2);
padding: var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.discovery-result strong {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.discovery-result--ok {
border-color: var(--color-success-text);
color: var(--color-success-text);
}
.discovery-result--fail {
border-color: var(--color-danger-text);
color: var(--color-danger-text);
}
.discovery-result .kv {
margin-top: var(--space-2);
color: var(--color-text);
}
.callback-row {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.install-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.install-bar .muted {
display: block;
font-size: var(--text-sm);
}
.install-bar .btn.disabled {
opacity: 0.6;
pointer-events: none;
}
.events {
display: inline-block;
min-width: 1.4rem;
text-align: center;
padding: 0 0.35rem;
border-radius: var(--radius-pill, 999px);
background: var(--color-bg-muted);
color: var(--color-text-muted);
}
.plugin-meta {
margin-bottom: var(--space-4);
}
.event-tag {
display: inline-block;
margin: 0 0.15rem 0.15rem 0;
padding: 0.05rem 0.35rem;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.retention-form {
border-top: 1px solid var(--color-border);
padding-top: var(--space-3);
margin-bottom: var(--space-3);
}
.retention-form h3 {
margin: 0 0 var(--space-2);
font-size: 1rem;
}
.mig-failed {
margin-top: var(--space-2);
}
.mig-failed__list {
max-height: 12rem;
overflow: auto;
padding: var(--space-2);
background: var(--color-bg-muted);
border-radius: var(--radius-sm);
font-size: var(--text-xs, 0.75rem);
white-space: pre-wrap;
word-break: break-all;
}
.smtp-test {
display: flex;
gap: var(--space-2);
}
.smtp-test input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.status--ok {
color: var(--color-success-text);
}
2026-06-17 17:06:30 -06:00
.admin {
max-width: 64rem;
margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.tabs button {
padding: 0.5rem 1rem;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button[aria-selected='true'] {
color: var(--color-text);
border-bottom-color: var(--color-primary);
}
.bar {
display: flex;
justify-content: flex-end;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.875rem;
}
.user-cell {
display: flex;
flex-direction: column;
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
}
.form input,
.form select {
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.btn {
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text-light);
border-color: transparent;
}
.status {
color: var(--color-text-muted);
padding: 2rem 0;
text-align: center;
}
.status--error {
color: var(--color-danger-text);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.8125rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
</style>