Files
tiny-forge/web/src/routes/settings/auth/+page.svelte
T
alexei.dolgolyov 32de5b26a8 feat(docker-watcher): phase 12 - hardening
Blue-green zero-downtime deploys, promote flow validation.
Dual auth: local (bcrypt + JWT) and OAuth2/OIDC (any provider).
Auth middleware, login page, auth settings UI.
Structured logging (slog JSON), config export to YAML.
Graceful shutdown with deploy draining.
Multi-stage Dockerfile and production docker-compose.yml.
Swap phase order: Volumes & Env before UI Polish.
2026-03-27 23:20:56 +03:00

318 lines
9.6 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
interface AuthSettings {
auth_mode: string;
oidc_client_id: string;
oidc_client_secret: string;
oidc_issuer_url: string;
oidc_redirect_url: string;
}
interface User {
id: string;
username: string;
email: string;
role: string;
created_at: string;
}
let settings = $state<AuthSettings>({
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 authHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`
};
}
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';
}
}
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';
}
}
async function saveSettings() {
saving = true;
message = '';
error = '';
try {
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;
}
}
async function addUser() {
if (!newUsername || !newPassword) {
error = 'Username and password are required';
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 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';
}
}
async function deleteUser(id: string) {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
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';
}
}
</script>
<div class="space-y-8">
<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>
</div>
{#if message}
<div class="rounded-md bg-green-50 p-3 text-sm text-green-700">{message}</div>
{/if}
{#if error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700">{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>
<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>
<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>
</div>
</div>
<!-- OIDC Configuration -->
{#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="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>
</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>
<!-- 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>
{#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}
<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>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="mt-4 text-sm text-gray-500">No users found.</p>
{/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-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>
</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>
</div>
</div>
</div>