feat(docker-watcher): phase 14 - frontend polish & modern UI
Design system with CSS custom properties (light/dark themes). 38 Lucide SVG icon components. Dark mode with system preference. EN/RU localization with i18n store. Skeleton loaders, empty states, toggle switches, micro-interactions. Responsive sidebar with mobile hamburger menu. All pages polished with consistent styling.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconSettings, IconDatabase, IconKey, IconShield } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -9,39 +11,46 @@
|
||||
let { children }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
{ href: '/settings', label: 'General' },
|
||||
{ href: '/settings/registries', label: 'Registries' },
|
||||
{ href: '/settings/credentials', label: 'Credentials' },
|
||||
{ href: '/settings/auth', label: 'Authentication' }
|
||||
{ href: '/settings', labelKey: 'settings.general', icon: 'general' },
|
||||
{ href: '/settings/registries', labelKey: 'settings.registries', icon: 'registries' },
|
||||
{ href: '/settings/credentials', labelKey: 'settings.credentials', icon: 'credentials' },
|
||||
{ href: '/settings/auth', labelKey: 'settings.authentication', icon: 'auth' }
|
||||
];
|
||||
|
||||
let currentPath = $derived($page.url.pathname);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/settings') {
|
||||
return currentPath === '/settings';
|
||||
}
|
||||
if (href === '/settings') return currentPath === '/settings';
|
||||
return currentPath.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<h1 class="mb-6 text-2xl font-bold text-[var(--text-primary)]">{$t('settings.title')}</h1>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<div class="flex flex-col gap-6 sm:flex-row">
|
||||
<!-- Sub-navigation -->
|
||||
<nav class="w-48 flex-shrink-0">
|
||||
<ul class="space-y-1">
|
||||
<nav class="w-full flex-shrink-0 sm:w-48">
|
||||
<ul class="flex gap-1 overflow-x-auto sm:flex-col sm:space-y-0.5">
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class="block rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||
class="flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-all duration-150
|
||||
{isActive(item.href)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
|
||||
? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
|
||||
>
|
||||
{item.label}
|
||||
{#if item.icon === 'general'}
|
||||
<IconSettings size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'registries'}
|
||||
<IconDatabase size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'credentials'}
|
||||
<IconKey size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'auth'}
|
||||
<IconShield size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{/if}
|
||||
{$t(item.labelKey)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -49,7 +58,7 @@
|
||||
</nav>
|
||||
|
||||
<!-- Settings content -->
|
||||
<div class="flex-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
import type { Settings } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconCopy, IconRefresh } from '$lib/components/icons';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let webhookUrl = $state('');
|
||||
let regenerating = $state(false);
|
||||
|
||||
// Settings fields
|
||||
let domain = $state('');
|
||||
let serverIp = $state('');
|
||||
let network = $state('');
|
||||
@@ -21,37 +23,26 @@
|
||||
|
||||
function validateDomain(value: string): string {
|
||||
if (!value.trim()) return 'Domain is required';
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) {
|
||||
return 'Invalid domain format';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return 'Invalid domain format';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateIp(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) {
|
||||
return 'Invalid IP address format';
|
||||
}
|
||||
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) return 'Invalid IP address format';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validatePollingInterval(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < 10 || num > 86400) {
|
||||
return 'Polling interval must be between 10 and 86400 seconds';
|
||||
}
|
||||
if (isNaN(num) || num < 10 || num > 86400) return 'Polling interval must be between 10 and 86400 seconds';
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateUrl(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
try {
|
||||
new URL(value.trim());
|
||||
return '';
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
try { new URL(value.trim()); return ''; } catch { return 'Invalid URL format'; }
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
@@ -79,8 +70,7 @@
|
||||
pollingInterval = settings.polling_interval ?? '';
|
||||
notificationUrl = settings.notification_url ?? '';
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load settings';
|
||||
toasts.error(message);
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -90,29 +80,21 @@
|
||||
try {
|
||||
const result = await getWebhookUrl();
|
||||
webhookUrl = result.url;
|
||||
} catch {
|
||||
// Webhook URL may not be configured yet
|
||||
}
|
||||
} catch { /* may not be configured */ }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validateAll()) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const payload: Partial<Settings> = {
|
||||
domain: domain.trim(),
|
||||
server_ip: serverIp.trim(),
|
||||
network: network.trim(),
|
||||
subdomain_pattern: subdomainPattern.trim(),
|
||||
polling_interval: pollingInterval.trim(),
|
||||
await updateSettings({
|
||||
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
||||
notification_url: notificationUrl.trim()
|
||||
};
|
||||
await updateSettings(payload);
|
||||
toasts.success('Settings saved successfully');
|
||||
});
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save settings';
|
||||
toasts.error(message);
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -123,155 +105,83 @@
|
||||
try {
|
||||
const result = await regenerateWebhookUrl();
|
||||
webhookUrl = result.url;
|
||||
toasts.success('Webhook URL regenerated');
|
||||
toasts.success($t('settingsGeneral.regenerated'));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to regenerate webhook URL';
|
||||
toasts.error(message);
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.regenerateFailed'));
|
||||
} finally {
|
||||
regenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadSettings();
|
||||
loadWebhookUrlValue();
|
||||
});
|
||||
$effect(() => { loadSettings(); loadWebhookUrlValue(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>General Settings - Docker Watcher</title>
|
||||
<title>{$t('settingsGeneral.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="2rem" width="12rem" />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<Skeleton height="4rem" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Global settings form -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">Global Configuration</h2>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.globalConfig')}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label="Domain"
|
||||
name="domain"
|
||||
bind:value={domain}
|
||||
placeholder="example.com"
|
||||
required
|
||||
error={errors.domain ?? ''}
|
||||
helpText="Base domain for subdomain routing"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Server IP"
|
||||
name="serverIp"
|
||||
bind:value={serverIp}
|
||||
placeholder="93.84.96.191"
|
||||
error={errors.serverIp ?? ''}
|
||||
helpText="Public IP address of the server"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Docker Network"
|
||||
name="network"
|
||||
bind:value={network}
|
||||
placeholder="staging-net"
|
||||
helpText="Docker network for deployed containers"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Subdomain Pattern"
|
||||
name="subdomainPattern"
|
||||
bind:value={subdomainPattern}
|
||||
placeholder="stage-{stage}-{project}"
|
||||
helpText="Pattern for auto-generated subdomains"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Polling Interval (seconds)"
|
||||
name="pollingInterval"
|
||||
type="number"
|
||||
bind:value={pollingInterval}
|
||||
placeholder="60"
|
||||
error={errors.pollingInterval ?? ''}
|
||||
helpText="How often to check registries for new tags (10-86400)"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Notification URL"
|
||||
name="notificationUrl"
|
||||
bind:value={notificationUrl}
|
||||
placeholder="https://notify.example.com/webhook"
|
||||
error={errors.notificationUrl ?? ''}
|
||||
helpText="Webhook URL for deploy notifications"
|
||||
/>
|
||||
<FormField label={$t('settingsGeneral.domain')} name="domain" bind:value={domain} placeholder="example.com" required error={errors.domain ?? ''} helpText={$t('settingsGeneral.domainHelp')} />
|
||||
<FormField label={$t('settingsGeneral.serverIp')} name="serverIp" bind:value={serverIp} placeholder="93.84.96.191" error={errors.serverIp ?? ''} helpText={$t('settingsGeneral.serverIpHelp')} />
|
||||
<FormField label={$t('settingsGeneral.dockerNetwork')} name="network" bind:value={network} placeholder="staging-net" helpText={$t('settingsGeneral.dockerNetworkHelp')} />
|
||||
<FormField label={$t('settingsGeneral.subdomainPattern')} name="subdomainPattern" bind:value={subdomainPattern} placeholder="stage-{'{stage}'}-{'{project}'}" helpText={$t('settingsGeneral.subdomainPatternHelp')} />
|
||||
<FormField label={$t('settingsGeneral.pollingInterval')} name="pollingInterval" type="number" bind:value={pollingInterval} placeholder="60" error={errors.pollingInterval ?? ''} helpText={$t('settingsGeneral.pollingIntervalHelp')} />
|
||||
<FormField label={$t('settingsGeneral.notificationUrl')} name="notificationUrl" bind:value={notificationUrl} placeholder="https://notify.example.com/webhook" error={errors.notificationUrl ?? ''} helpText={$t('settingsGeneral.notificationUrlHelp')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={saving}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook URL section -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800">Webhook URL</h2>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
This secret URL receives image push notifications from your CI pipeline.
|
||||
</p>
|
||||
<!-- Webhook URL -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.webhookUrl')}</h2>
|
||||
<p class="mb-3 text-sm text-[var(--text-secondary)]">{$t('settingsGeneral.webhookDesc')}</p>
|
||||
|
||||
{#if webhookUrl}
|
||||
<div class="flex items-center gap-3">
|
||||
<code
|
||||
class="flex-1 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm font-mono text-gray-700 break-all"
|
||||
>
|
||||
<code class="flex-1 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 font-mono text-sm text-[var(--text-secondary)] break-all">
|
||||
{webhookUrl}
|
||||
</code>
|
||||
<button
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(webhookUrl);
|
||||
toasts.info('Webhook URL copied to clipboard');
|
||||
}}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
onclick={() => { navigator.clipboard.writeText(webhookUrl); toasts.info($t('settingsGeneral.copied')); }}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
Copy
|
||||
<IconCopy size={16} />
|
||||
{$t('settingsGeneral.copy')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400 italic">No webhook URL configured</p>
|
||||
<p class="text-sm text-[var(--text-tertiary)] italic">{$t('settingsGeneral.noWebhookUrl')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
onclick={handleRegenerateWebhook}
|
||||
disabled={regenerating}
|
||||
class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{regenerating ? 'Regenerating...' : 'Regenerate URL'}
|
||||
{#if regenerating}<IconLoader size={16} />{/if}
|
||||
<IconRefresh size={16} />
|
||||
{regenerating ? $t('settingsGeneral.regenerating') : $t('settingsGeneral.regenerateUrl')}
|
||||
</button>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Warning: regenerating will invalidate the current URL. Update your CI pipelines.
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.regenerateWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
|
||||
interface AuthSettings {
|
||||
auth_mode: string;
|
||||
@@ -18,299 +21,179 @@
|
||||
}
|
||||
|
||||
let settings = $state<AuthSettings>({
|
||||
auth_mode: 'local',
|
||||
oidc_client_id: '',
|
||||
oidc_client_secret: '',
|
||||
oidc_issuer_url: '',
|
||||
oidc_redirect_url: ''
|
||||
auth_mode: 'local', oidc_client_id: '', oidc_client_secret: '', oidc_issuer_url: '', oidc_redirect_url: ''
|
||||
});
|
||||
let users = $state<User[]>([]);
|
||||
let saving = $state(false);
|
||||
let message = $state('');
|
||||
let error = $state('');
|
||||
|
||||
// New user form
|
||||
let newUsername = $state('');
|
||||
let newPassword = $state('');
|
||||
let newEmail = $state('');
|
||||
let newRole = $state('viewer');
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem('auth_token') ?? '';
|
||||
}
|
||||
function getToken(): string { return localStorage.getItem('auth_token') ?? ''; }
|
||||
function authHeaders(): Record<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; }
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${getToken()}`
|
||||
};
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadSettings(), loadUsers()]);
|
||||
});
|
||||
onMount(async () => { await Promise.all([loadSettings(), loadUsers()]); });
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', { headers: authHeaders() });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
settings = envelope.data;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load settings';
|
||||
}
|
||||
try { const res = await fetch('/api/auth/settings', { headers: authHeaders() }); const envelope = await res.json(); if (envelope.success) settings = envelope.data; }
|
||||
catch (err: unknown) { error = err instanceof Error ? err.message : $t('settingsAuth.loadFailed'); }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', { headers: authHeaders() });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
users = envelope.data ?? [];
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load users';
|
||||
}
|
||||
try { const res = await fetch('/api/auth/users', { headers: authHeaders() }); const envelope = await res.json(); if (envelope.success) users = envelope.data ?? []; }
|
||||
catch (err: unknown) { error = err instanceof Error ? err.message : $t('settingsAuth.loadFailed'); }
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving = true;
|
||||
message = '';
|
||||
error = '';
|
||||
|
||||
saving = true; message = ''; error = '';
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
const res = await fetch('/api/auth/settings', { method: 'PUT', headers: authHeaders(), body: JSON.stringify(settings) });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
message = 'Settings saved';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to save';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
if (envelope.success) message = $t('settingsAuth.saved'); else error = envelope.error ?? $t('settingsAuth.saveFailed');
|
||||
} catch (err: unknown) { error = err instanceof Error ? err.message : 'Network error'; } finally { saving = false; }
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
if (!newUsername || !newPassword) {
|
||||
error = 'Username and password are required';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newUsername || !newPassword) { error = $t('settingsAuth.usernameRequired'); return; }
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({
|
||||
username: newUsername,
|
||||
password: newPassword,
|
||||
email: newEmail,
|
||||
role: newRole
|
||||
})
|
||||
});
|
||||
const res = await fetch('/api/auth/users', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ username: newUsername, password: newPassword, email: newEmail, role: newRole }) });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
newUsername = '';
|
||||
newPassword = '';
|
||||
newEmail = '';
|
||||
newRole = 'viewer';
|
||||
await loadUsers();
|
||||
message = 'User created';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to create user';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
}
|
||||
if (envelope.success) { newUsername = ''; newPassword = ''; newEmail = ''; newRole = 'viewer'; await loadUsers(); message = $t('settingsAuth.userCreated'); }
|
||||
else error = envelope.error ?? $t('settingsAuth.createFailed');
|
||||
} catch (err: unknown) { error = err instanceof Error ? err.message : 'Network error'; }
|
||||
}
|
||||
|
||||
async function deleteUser(id: string) {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
if (!confirm($t('settingsAuth.deleteConfirm'))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/auth/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
const res = await fetch(`/api/auth/users/${id}`, { method: 'DELETE', headers: authHeaders() });
|
||||
const envelope = await res.json();
|
||||
if (envelope.success) {
|
||||
await loadUsers();
|
||||
message = 'User deleted';
|
||||
} else {
|
||||
error = envelope.error ?? 'Failed to delete user';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Network error';
|
||||
}
|
||||
if (envelope.success) { await loadUsers(); message = $t('settingsAuth.userDeleted'); }
|
||||
else error = envelope.error ?? $t('settingsAuth.deleteFailed');
|
||||
} catch (err: unknown) { error = err instanceof Error ? err.message : 'Network error'; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Authentication Settings</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Configure authentication mode and manage users.</p>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsAuth.title')}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('settingsAuth.description')}</p>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
<div class="rounded-md bg-green-50 p-3 text-sm text-green-700">{message}</div>
|
||||
<div class="rounded-lg bg-emerald-50 p-3 text-sm text-emerald-700">{message}</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700">{error}</div>
|
||||
<div class="rounded-lg bg-[var(--color-danger-light)] p-3 text-sm text-[var(--color-danger)]">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Auth Mode Toggle -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Authentication Mode</h2>
|
||||
<!-- Auth Mode -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.authMode')}</h3>
|
||||
<div class="mt-4 flex gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={settings.auth_mode} value="local" class="text-indigo-600" />
|
||||
<span class="text-sm font-medium text-gray-700">Local (username/password)</span>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" bind:group={settings.auth_mode} value="local" class="h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<span class="text-sm font-medium text-[var(--text-secondary)]">{$t('settingsAuth.local')}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={settings.auth_mode} value="oidc" class="text-indigo-600" />
|
||||
<span class="text-sm font-medium text-gray-700">OIDC (SSO)</span>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" bind:group={settings.auth_mode} value="oidc" class="h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<span class="text-sm font-medium text-[var(--text-secondary)]">{$t('settingsAuth.oidc')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC Configuration -->
|
||||
<!-- OIDC Config -->
|
||||
{#if settings.auth_mode === 'oidc'}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">OIDC Provider Configuration</h2>
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.oidcConfig')}</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label for="issuer" class="block text-sm font-medium text-gray-700">Issuer URL</label>
|
||||
<input
|
||||
id="issuer"
|
||||
type="url"
|
||||
bind:value={settings.oidc_issuer_url}
|
||||
placeholder="https://auth.example.com/application/o/docker-watcher/"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
||||
<input
|
||||
id="client_id"
|
||||
type="text"
|
||||
bind:value={settings.oidc_client_id}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
||||
<input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
bind:value={settings.oidc_client_secret}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="redirect" class="block text-sm font-medium text-gray-700">Redirect URL</label>
|
||||
<input
|
||||
id="redirect"
|
||||
type="url"
|
||||
bind:value={settings.oidc_redirect_url}
|
||||
placeholder="https://watcher.example.com/api/auth/oidc/callback"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
{#each [
|
||||
{ id: 'issuer', label: $t('settingsAuth.issuerUrl'), type: 'url', key: 'oidc_issuer_url', placeholder: 'https://auth.example.com/application/o/docker-watcher/' },
|
||||
{ id: 'client_id', label: $t('settingsAuth.clientId'), type: 'text', key: 'oidc_client_id', placeholder: '' },
|
||||
{ id: 'client_secret', label: $t('settingsAuth.clientSecret'), type: 'password', key: 'oidc_client_secret', placeholder: '' },
|
||||
{ id: 'redirect', label: $t('settingsAuth.redirectUrl'), type: 'url', key: 'oidc_redirect_url', placeholder: 'https://watcher.example.com/api/auth/oidc/callback' }
|
||||
] as field}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for={field.id} class="text-sm font-medium text-[var(--text-primary)]">{field.label}</label>
|
||||
<input
|
||||
id={field.id}
|
||||
type={field.type}
|
||||
bind:value={settings[field.key as keyof AuthSettings]}
|
||||
placeholder={field.placeholder}
|
||||
class="w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={saveSettings}
|
||||
disabled={saving}
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
<button onclick={saveSettings} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsAuth.saving') : $t('settingsAuth.saveSettings')}
|
||||
</button>
|
||||
|
||||
<!-- Local Users Management -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Local Users</h2>
|
||||
<!-- Local Users -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.localUsers')}</h3>
|
||||
|
||||
{#if users.length > 0}
|
||||
<table class="mt-4 min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Username</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Email</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Role</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Created</th>
|
||||
<th class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{#each users as user}
|
||||
<div class="mt-4 overflow-hidden rounded-xl border border-[var(--border-primary)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<td class="px-3 py-2 text-sm text-gray-900">{user.username}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500">{user.email || '-'}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="inline-flex rounded-full px-2 text-xs font-semibold {user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-800'}">
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500">{user.created_at}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button
|
||||
onclick={() => deleteUser(user.id)}
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium uppercase text-[var(--text-tertiary)]">{$t('settingsAuth.username')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium uppercase text-[var(--text-tertiary)]">{$t('settingsAuth.email')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium uppercase text-[var(--text-tertiary)]">{$t('settingsAuth.role')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium uppercase text-[var(--text-tertiary)]">{$t('settingsAuth.created')}</th>
|
||||
<th class="px-4 py-2.5"></th>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each users as user}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-primary)]">{user.username}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.email || '-'}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-semibold {user.role === 'admin' ? 'bg-purple-50 text-purple-700' : 'bg-gray-100 text-gray-700'}">
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<button onclick={() => deleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors">
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-4 text-sm text-gray-500">No users found.</p>
|
||||
<div class="mt-4">
|
||||
<EmptyState title={$t('empty.noUsers')} description={$t('empty.noUsersDesc')} icon="users" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Add User</h3>
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||
<h4 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsAuth.addUser')}</h4>
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
placeholder="Username"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="Password"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder="Email (optional)"
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
<select
|
||||
bind:value={newRole}
|
||||
class="rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
<input type="text" bind:value={newUsername} placeholder={$t('settingsAuth.username')} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" />
|
||||
<input type="password" bind:value={newPassword} placeholder={$t('settingsAuth.password')} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" />
|
||||
<input type="email" bind:value={newEmail} placeholder="{$t('settingsAuth.email')} (optional)" class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" />
|
||||
<select bind:value={newRole} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]">
|
||||
<option value="viewer">{$t('settingsAuth.viewer')}</option>
|
||||
<option value="admin">{$t('settingsAuth.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onclick={addUser}
|
||||
class="mt-3 rounded-md bg-gray-800 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-gray-900"
|
||||
>
|
||||
Add User
|
||||
<button onclick={addUser} class="mt-3 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors active:animate-press">
|
||||
<IconPlus size={16} />
|
||||
{$t('settingsAuth.addUser')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings } from '$lib/api';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconCheck, IconEdit } from '$lib/components/icons';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
|
||||
// NPM credentials
|
||||
let npmUrl = $state('');
|
||||
let npmEmail = $state('');
|
||||
let npmPassword = $state('');
|
||||
let npmHasCredentials = $state(false);
|
||||
let editingNpm = $state(false);
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
function validateNpmForm(): boolean {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!npmUrl.trim()) {
|
||||
newErrors.npmUrl = 'NPM URL is required';
|
||||
} else {
|
||||
try {
|
||||
new URL(npmUrl.trim());
|
||||
} catch {
|
||||
newErrors.npmUrl = 'Invalid URL format';
|
||||
}
|
||||
}
|
||||
if (!npmEmail.trim()) {
|
||||
newErrors.npmEmail = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) {
|
||||
newErrors.npmEmail = 'Invalid email format';
|
||||
}
|
||||
if (editingNpm && !npmPassword.trim()) {
|
||||
newErrors.npmPassword = 'Password is required when updating credentials';
|
||||
}
|
||||
if (!npmUrl.trim()) { newErrors.npmUrl = 'NPM URL is required'; } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = 'Invalid URL format'; } }
|
||||
if (!npmEmail.trim()) { newErrors.npmEmail = 'Email is required'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) { newErrors.npmEmail = 'Invalid email format'; }
|
||||
if (editingNpm && !npmPassword.trim()) { newErrors.npmPassword = 'Password is required when updating credentials'; }
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
@@ -44,178 +30,83 @@
|
||||
const settings = await getSettings();
|
||||
npmUrl = settings.npm_url ?? '';
|
||||
npmEmail = settings.npm_email ?? '';
|
||||
// If npm_password is present (even masked), credentials exist
|
||||
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
||||
npmPassword = '';
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load credentials';
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
|
||||
}
|
||||
|
||||
async function handleSaveNpm() {
|
||||
if (!validateNpmForm()) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const payload: Record<string, string> = {
|
||||
npm_url: npmUrl.trim(),
|
||||
npm_email: npmEmail.trim()
|
||||
};
|
||||
if (npmPassword.trim()) {
|
||||
payload.npm_password = npmPassword.trim();
|
||||
}
|
||||
const payload: Record<string, string> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
|
||||
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
|
||||
await updateSettings(payload);
|
||||
npmHasCredentials = true;
|
||||
editingNpm = false;
|
||||
npmPassword = '';
|
||||
toasts.success('NPM credentials saved');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save NPM credentials';
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
toasts.success($t('settingsCredentials.saved'));
|
||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); } finally { saving = false; }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadCredentials();
|
||||
});
|
||||
$effect(() => { loadCredentials(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Credentials - Docker Watcher</title>
|
||||
<title>{$t('settingsCredentials.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800">Credentials</h2>
|
||||
<p class="text-sm text-gray-500">
|
||||
Manage credentials for Nginx Proxy Manager and registry tokens. All values are encrypted at
|
||||
rest.
|
||||
</p>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.title')}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('settingsCredentials.description')}</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="space-y-4"><Skeleton height="12rem" /></div>
|
||||
{:else}
|
||||
<!-- NPM Credentials -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-800">Nginx Proxy Manager</h3>
|
||||
<p class="text-xs text-gray-500">Credentials for managing proxy hosts via NPM API</p>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.npm')}</h3>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsCredentials.npmDesc')}</p>
|
||||
</div>
|
||||
{#if npmHasCredentials && !editingNpm}
|
||||
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
Configured
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700">
|
||||
<IconCheck size={12} />
|
||||
{$t('settingsCredentials.configured')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !editingNpm && npmHasCredentials}
|
||||
<!-- Masked display -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500">URL</p>
|
||||
<p class="text-sm text-gray-700">{npmUrl || 'Not set'}</p>
|
||||
<div class="space-y-2">
|
||||
{#each [{ label: $t('settingsCredentials.npmUrl'), value: npmUrl }, { label: $t('settingsCredentials.email'), value: npmEmail }, { label: $t('settingsCredentials.password'), value: '--------' }] as item}
|
||||
<div class="flex items-center justify-between rounded-lg bg-[var(--surface-card-hover)] px-4 py-2.5">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)]">{item.label}</p>
|
||||
<p class="text-sm text-[var(--text-secondary)] {item.label === $t('settingsCredentials.password') ? 'font-mono' : ''}">{item.value || 'Not set'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500">Email</p>
|
||||
<p class="text-sm text-gray-700">{npmEmail || 'Not set'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500">Password</p>
|
||||
<p class="text-sm font-mono text-gray-700">--------</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => {
|
||||
editingNpm = true;
|
||||
}}
|
||||
class="mt-2 rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Change Credentials
|
||||
{/each}
|
||||
<button onclick={() => { editingNpm = true; }} class="mt-3 inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<IconEdit size={16} />
|
||||
{$t('settingsCredentials.changeCredentials')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Edit form -->
|
||||
<div class="space-y-4">
|
||||
<FormField
|
||||
label="NPM URL"
|
||||
name="npmUrl"
|
||||
bind:value={npmUrl}
|
||||
placeholder="http://npm:81"
|
||||
required
|
||||
error={errors.npmUrl ?? ''}
|
||||
helpText="Nginx Proxy Manager API URL"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
name="npmEmail"
|
||||
type="email"
|
||||
bind:value={npmEmail}
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
error={errors.npmEmail ?? ''}
|
||||
helpText="NPM admin email"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
name="npmPassword"
|
||||
type="password"
|
||||
bind:value={npmPassword}
|
||||
placeholder={npmHasCredentials ? '(enter new password)' : 'npm-password'}
|
||||
required={editingNpm}
|
||||
error={errors.npmPassword ?? ''}
|
||||
helpText={npmHasCredentials
|
||||
? 'Enter the new password to replace the existing one'
|
||||
: 'NPM admin password (will be encrypted)'}
|
||||
/>
|
||||
|
||||
<FormField label={$t('settingsCredentials.npmUrl')} name="npmUrl" bind:value={npmUrl} placeholder="http://npm:81" required error={errors.npmUrl ?? ''} helpText={$t('settingsCredentials.npmUrlHelp')} />
|
||||
<FormField label={$t('settingsCredentials.email')} name="npmEmail" type="email" bind:value={npmEmail} placeholder="admin@example.com" required error={errors.npmEmail ?? ''} helpText={$t('settingsCredentials.emailHelp')} />
|
||||
<FormField label={$t('settingsCredentials.password')} name="npmPassword" type="password" bind:value={npmPassword} placeholder={npmHasCredentials ? '(enter new password)' : 'npm-password'} required={editingNpm} error={errors.npmPassword ?? ''} helpText={npmHasCredentials ? $t('settingsCredentials.passwordHelpEdit') : $t('settingsCredentials.passwordHelpNew')} />
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleSaveNpm}
|
||||
disabled={saving}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
<button onclick={handleSaveNpm} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsCredentials.saving') : $t('settingsCredentials.save')}
|
||||
</button>
|
||||
{#if npmHasCredentials}
|
||||
<button
|
||||
onclick={() => {
|
||||
editingNpm = false;
|
||||
npmPassword = '';
|
||||
errors = {};
|
||||
}}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
<button onclick={() => { editingNpm = false; npmPassword = ''; errors = {}; }} class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -223,15 +114,12 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Registry Tokens info -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h3 class="text-sm font-semibold text-gray-800">Registry Tokens</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Registry authentication tokens are managed per-registry in the
|
||||
<a href="/settings/registries" class="text-blue-600 hover:text-blue-700 underline"
|
||||
>Registries</a
|
||||
>
|
||||
section. Each registry stores its token encrypted in the database.
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.registryTokens')}</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
{$t('settingsCredentials.registryTokensDesc')}
|
||||
<a href="/settings/registries" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] underline transition-colors">{$t('settingsCredentials.registriesLink')}</a>
|
||||
{$t('settingsCredentials.registryTokensSuffix')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
listRegistries,
|
||||
createRegistry,
|
||||
updateRegistry,
|
||||
deleteRegistry,
|
||||
testRegistry
|
||||
} from '$lib/api';
|
||||
import { listRegistries, createRegistry, updateRegistry, deleteRegistry, testRegistry } from '$lib/api';
|
||||
import type { Registry } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
|
||||
|
||||
let registries = $state<Registry[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
// Form state
|
||||
let showForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let formName = $state('');
|
||||
@@ -28,285 +25,125 @@
|
||||
function validateForm(): boolean {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formName.trim()) newErrors.name = 'Name is required';
|
||||
if (!formUrl.trim()) {
|
||||
newErrors.url = 'URL is required';
|
||||
} else {
|
||||
try {
|
||||
new URL(formUrl.trim());
|
||||
} catch {
|
||||
newErrors.url = 'Invalid URL format';
|
||||
}
|
||||
}
|
||||
if (!formToken.trim() && !editingId) {
|
||||
newErrors.token = 'Token is required for new registries';
|
||||
}
|
||||
if (!formUrl.trim()) { newErrors.url = 'URL is required'; } else { try { new URL(formUrl.trim()); } catch { newErrors.url = 'Invalid URL format'; } }
|
||||
if (!formToken.trim() && !editingId) newErrors.token = 'Token is required for new registries';
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
showForm = false;
|
||||
editingId = null;
|
||||
formName = '';
|
||||
formUrl = '';
|
||||
formType = 'gitea';
|
||||
formToken = '';
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function startEdit(registry: Registry) {
|
||||
editingId = registry.id;
|
||||
formName = registry.name;
|
||||
formUrl = registry.url;
|
||||
formType = registry.type;
|
||||
formToken = '';
|
||||
showForm = true;
|
||||
errors = {};
|
||||
}
|
||||
function resetForm() { showForm = false; editingId = null; formName = ''; formUrl = ''; formType = 'gitea'; formToken = ''; errors = {}; }
|
||||
function startEdit(registry: Registry) { editingId = registry.id; formName = registry.name; formUrl = registry.url; formType = registry.type; formToken = ''; showForm = true; errors = {}; }
|
||||
|
||||
async function loadRegistryList() {
|
||||
loading = true;
|
||||
try {
|
||||
registries = await listRegistries();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load registries';
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
try { registries = await listRegistries(); } catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.loadFailed')); } finally { loading = false; }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
formSaving = true;
|
||||
try {
|
||||
const payload: Partial<Registry> = {
|
||||
name: formName.trim(),
|
||||
url: formUrl.trim(),
|
||||
type: formType
|
||||
};
|
||||
if (formToken.trim()) {
|
||||
payload.token = formToken.trim();
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await updateRegistry(editingId, payload);
|
||||
toasts.success('Registry updated');
|
||||
} else {
|
||||
await createRegistry(payload);
|
||||
toasts.success('Registry added');
|
||||
}
|
||||
const payload: Partial<Registry> = { name: formName.trim(), url: formUrl.trim(), type: formType };
|
||||
if (formToken.trim()) payload.token = formToken.trim();
|
||||
if (editingId) { await updateRegistry(editingId, payload); toasts.success($t('settingsRegistries.registryUpdated')); }
|
||||
else { await createRegistry(payload); toasts.success($t('settingsRegistries.registryAdded')); }
|
||||
resetForm();
|
||||
await loadRegistryList();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save registry';
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
formSaving = false;
|
||||
}
|
||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.saveFailed')); } finally { formSaving = false; }
|
||||
}
|
||||
|
||||
async function handleDelete(registry: Registry) {
|
||||
if (!confirm(`Delete registry "${registry.name}"? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
await deleteRegistry(registry.id);
|
||||
toasts.success(`Registry "${registry.name}" deleted`);
|
||||
await loadRegistryList();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete registry';
|
||||
toasts.error(message);
|
||||
}
|
||||
if (!confirm($t('settingsRegistries.deleteConfirm', { name: registry.name }))) return;
|
||||
try { await deleteRegistry(registry.id); toasts.success($t('settingsRegistries.registryDeleted', { name: registry.name })); await loadRegistryList(); }
|
||||
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.deleteFailed')); }
|
||||
}
|
||||
|
||||
async function handleTestConnection(registry: Registry) {
|
||||
testingId = registry.id;
|
||||
try {
|
||||
await testRegistry(registry.id);
|
||||
toasts.success(`Connection to "${registry.name}" successful`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Connection test failed';
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
testingId = null;
|
||||
}
|
||||
try { await testRegistry(registry.id); toasts.success($t('settingsRegistries.testSuccess', { name: registry.name })); }
|
||||
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.testFailed')); }
|
||||
finally { testingId = null; }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadRegistryList();
|
||||
});
|
||||
$effect(() => { loadRegistryList(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Registries - Docker Watcher</title>
|
||||
<title>{$t('settingsRegistries.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800">Container Registries</h2>
|
||||
<p class="text-sm text-gray-500">Manage your container registries for image detection.</p>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsRegistries.title')}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('settingsRegistries.description')}</p>
|
||||
</div>
|
||||
{#if !showForm}
|
||||
<button
|
||||
onclick={() => {
|
||||
resetForm();
|
||||
showForm = true;
|
||||
}}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Add Registry
|
||||
<button onclick={() => { resetForm(); showForm = true; }} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors active:animate-press">
|
||||
<IconPlus size={16} />
|
||||
{$t('settingsRegistries.addRegistry')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Form -->
|
||||
{#if showForm}
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-6">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-800">
|
||||
{editingId ? 'Edit Registry' : 'Add New Registry'}
|
||||
<div class="rounded-xl border border-[var(--color-brand-200)] bg-[var(--color-brand-50)]/30 p-6 animate-scale-in">
|
||||
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">
|
||||
{editingId ? $t('settingsRegistries.editRegistry') : $t('settingsRegistries.addNewRegistry')}
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label="Name"
|
||||
name="registryName"
|
||||
bind:value={formName}
|
||||
placeholder="gitea"
|
||||
required
|
||||
error={errors.name ?? ''}
|
||||
helpText="A friendly name for this registry"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="URL"
|
||||
name="registryUrl"
|
||||
bind:value={formUrl}
|
||||
placeholder="https://git.example.com"
|
||||
required
|
||||
error={errors.url ?? ''}
|
||||
helpText="Registry base URL"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="registryType" class="text-sm font-medium text-gray-700">Type</label>
|
||||
<select
|
||||
id="registryType"
|
||||
bind:value={formType}
|
||||
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<FormField label={$t('settingsRegistries.name')} name="registryName" bind:value={formName} placeholder="gitea" required error={errors.name ?? ''} helpText={$t('settingsRegistries.nameHelp')} />
|
||||
<FormField label={$t('settingsRegistries.url')} name="registryUrl" bind:value={formUrl} placeholder="https://git.example.com" required error={errors.url ?? ''} helpText={$t('settingsRegistries.urlHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="registryType" class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsRegistries.type')}</label>
|
||||
<select id="registryType" bind:value={formType} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]">
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="docker_hub">Docker Hub</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500">Registry type for API compatibility</p>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsRegistries.typeHelp')}</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Token"
|
||||
name="registryToken"
|
||||
type="password"
|
||||
bind:value={formToken}
|
||||
placeholder={editingId ? '(leave empty to keep current)' : 'registry-access-token'}
|
||||
required={!editingId}
|
||||
error={errors.token ?? ''}
|
||||
helpText={editingId
|
||||
? 'Leave empty to keep the existing token'
|
||||
: 'API token for authentication'}
|
||||
/>
|
||||
<FormField label={$t('settingsRegistries.token')} name="registryToken" type="password" bind:value={formToken} placeholder={editingId ? '(leave empty to keep current)' : 'registry-access-token'} required={!editingId} error={errors.token ?? ''} helpText={editingId ? $t('settingsRegistries.tokenHelpEdit') : $t('settingsRegistries.tokenHelpNew')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={formSaving}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{formSaving ? 'Saving...' : editingId ? 'Update' : 'Add Registry'}
|
||||
<button onclick={handleSave} disabled={formSaving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
{#if formSaving}<IconLoader size={16} />{/if}
|
||||
{formSaving ? $t('settingsRegistries.saving') : editingId ? $t('settingsRegistries.update') : $t('settingsRegistries.addRegistry')}
|
||||
</button>
|
||||
<button
|
||||
onclick={resetForm}
|
||||
disabled={formSaving}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
<button onclick={resetForm} disabled={formSaving} class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Registry List -->
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="space-y-3">
|
||||
{#each Array(2) as _}
|
||||
<Skeleton height="5rem" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if registries.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 p-8 text-center">
|
||||
<p class="text-sm text-gray-500">No registries configured yet.</p>
|
||||
{#if !showForm}
|
||||
<button
|
||||
onclick={() => {
|
||||
showForm = true;
|
||||
}}
|
||||
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Add your first registry
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<EmptyState title={$t('empty.noRegistries')} description={$t('empty.noRegistriesDesc')} actionLabel={$t('settingsRegistries.addFirst')} onaction={() => { showForm = true; }} icon="registries" />
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each registries as registry (registry.id)}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center justify-between rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-150 hover:shadow-[var(--shadow-md)]">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold text-gray-800">{registry.name}</h3>
|
||||
<span
|
||||
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
|
||||
>
|
||||
{registry.type}
|
||||
</span>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{registry.name}</h3>
|
||||
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-tertiary)]">{registry.type}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">{registry.url}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{registry.url}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => handleTestConnection(registry)}
|
||||
disabled={testingId === registry.id}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{testingId === registry.id ? 'Testing...' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => startEdit(registry)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(registry)}
|
||||
class="rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
<button onclick={() => handleTestConnection(registry)} disabled={testingId === registry.id} class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] disabled:opacity-50 transition-colors">
|
||||
{#if testingId === registry.id}<IconLoader size={14} />{:else}<IconWifi size={14} />{/if}
|
||||
{testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')}
|
||||
</button>
|
||||
<button onclick={() => startEdit(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"><IconEdit size={16} /></button>
|
||||
<button onclick={() => handleDelete(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors"><IconTrash size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user