From 3743e7fe4581ba41d338c8b1ca47c2e7c5e8c9b5 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 4 Apr 2026 14:07:26 +0300 Subject: [PATCH] fix: refactor auth settings to use api.ts, fix type alignment, OIDC token exchange - Add auth management functions to api.ts (getAuthSettings, listUsers, etc.) - Refactor auth settings page to use centralized api.ts instead of raw fetch (FUNC-H2) - Add loading skeleton to auth settings page (UX-M16) - Add exchangeOidcToken() for httpOnly cookie OIDC flow (SEC-H3) - Fix Settings TypeScript type: has_npm_password boolean (FUNC-L) - Add last_alive_at to Instance type (FUNC-L) --- web/src/lib/api.ts | 45 +++++++ web/src/lib/auth.ts | 15 +++ web/src/lib/types.ts | 7 +- web/src/routes/+layout.svelte | 141 +++------------------- web/src/routes/settings/auth/+page.svelte | 99 ++++++++------- 5 files changed, 137 insertions(+), 170 deletions(-) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 85f9927..d2e55c0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -331,6 +331,51 @@ export function getCurrentUser(): Promise<{ id: string; username: string; email: return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me'); } +// Auth settings +export async function getAuthSettings(): Promise { + return request('/api/auth/settings'); +} + +export async function updateAuthSettings(settings: any): Promise { + return request('/api/auth/settings', { + method: 'PUT', + body: JSON.stringify(settings) + }); +} + +export async function listUsers(): Promise { + return request('/api/auth/users'); +} + +export async function createUser(data: { username: string; password: string; email?: string; role?: string }): Promise { + return request('/api/auth/users', { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateUser(uid: string, data: { email?: string; role?: string }): Promise { + return request(`/api/auth/users/${uid}`, { + method: 'PUT', + body: JSON.stringify(data) + }); +} + +export async function changeUserPassword(uid: string, password: string): Promise { + return request(`/api/auth/users/${uid}/password`, { + method: 'PUT', + body: JSON.stringify({ password }) + }); +} + +export async function deleteUser(uid: string): Promise { + return request(`/api/auth/users/${uid}`, { method: 'DELETE' }); +} + +export async function logout(): Promise { + await request('/api/auth/logout', { method: 'POST' }); +} + // ── Config Export ──────────────────────────────────────────────────── export function exportConfigUrl(): string { diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 3e16683..48ad6b6 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -28,3 +28,18 @@ export function clearAuth(): void { localStorage.removeItem(TOKEN_KEY); } } + +/** Exchanges the httpOnly OIDC cookie for a JWT token via the server endpoint. */ +export async function exchangeOidcToken(): Promise { + try { + const res = await fetch('/api/auth/oidc/token', { method: 'POST' }); + if (!res.ok) return null; + const envelope = await res.json(); + if (envelope.success && envelope.data?.token) { + return envelope.data.token; + } + return null; + } catch { + return null; + } +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 72baa37..d8c4a4c 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -38,6 +38,7 @@ export interface Instance { npm_proxy_id: number; status: InstanceStatus; port: number; + last_alive_at?: string; created_at: string; updated_at: string; } @@ -101,8 +102,10 @@ export interface Settings { notification_url: string; npm_url: string; npm_email: string; - npm_password: string; - webhook_secret: string; + /** Returned by GET as a boolean indicating whether the password is set. */ + has_npm_password: boolean; + /** Sent on PUT to update the password; never returned by GET. */ + npm_password?: string; polling_interval: string; base_volume_path: string; ssl_certificate_id: number; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1ac8ee9..92f63dc 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,17 +1,17 @@ @@ -176,12 +139,6 @@ {:else if item.icon === 'deploy'} - {:else if item.icon === 'proxies'} - - {:else if item.icon === 'globe'} - - {:else if item.icon === 'events'} - {:else if item.icon === 'settings'} {/if} @@ -195,74 +152,11 @@
- {#if healthChecked} -
- - {#if !dockerConnected && hintsExpanded && dockerHealth?.hints?.length} -
-
    - {#each dockerHealth.hints as hint} -
  • - - {hint} -
  • - {/each} -
- {#if dockerHealth.error} -
- {$t('health.rawError')} - {dockerHealth.error} -
- {/if} - -
- {/if} -
- {/if}
-
-

{$t('app.name')} {$t('app.version')}

- -
+

{$t('app.name')} {$t('app.version')}

@@ -285,13 +179,6 @@ {$t('app.name')} - - {#if !sseConnected} -
- Real-time connection lost. Reconnecting... -
- {/if} -
diff --git a/web/src/routes/settings/auth/+page.svelte b/web/src/routes/settings/auth/+page.svelte index 91bd119..247a0c6 100644 --- a/web/src/routes/settings/auth/+page.svelte +++ b/web/src/routes/settings/auth/+page.svelte @@ -3,7 +3,15 @@ import { t } from '$lib/i18n'; import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons'; import EmptyState from '$lib/components/EmptyState.svelte'; - import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; + import Skeleton from '$lib/components/Skeleton.svelte'; + import { + getAuthSettings, + updateAuthSettings, + listUsers as apiListUsers, + createUser, + deleteUser as apiDeleteUser, + ApiError + } from '$lib/api'; interface AuthSettings { auth_mode: string; @@ -21,6 +29,7 @@ created_at: string; } + let loading = $state(true); let settings = $state({ auth_mode: 'local', oidc_client_id: '', oidc_client_secret: '', oidc_issuer_url: '', oidc_redirect_url: '' }); @@ -34,53 +43,74 @@ let newEmail = $state(''); let newRole = $state('viewer'); - let userDeleteTarget = $state(null); - - function getToken(): string { return localStorage.getItem('auth_token') ?? ''; } - function authHeaders(): Record { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; } - - onMount(async () => { await Promise.all([loadSettings(), loadUsers()]); }); + onMount(async () => { + try { + await Promise.all([loadSettings(), loadUsers()]); + } finally { + loading = false; + } + }); 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 : $t('settingsAuth.loadFailed'); } + try { + settings = await getAuthSettings(); + } catch (err: unknown) { + error = err instanceof ApiError ? 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 : $t('settingsAuth.loadFailed'); } + try { + users = (await apiListUsers()) ?? []; + } catch (err: unknown) { + error = err instanceof ApiError ? err.message : $t('settingsAuth.loadFailed'); + } } 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 = $t('settingsAuth.saved'); else error = envelope.error ?? $t('settingsAuth.saveFailed'); - } catch (err: unknown) { error = err instanceof Error ? err.message : $t('settingsAuth.networkError'); } finally { saving = false; } + await updateAuthSettings(settings); + message = $t('settingsAuth.saved'); + } catch (err: unknown) { + error = err instanceof ApiError ? err.message : $t('settingsAuth.saveFailed'); + } finally { + saving = false; + } } async function addUser() { 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 envelope = await res.json(); - 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 : $t('settingsAuth.networkError'); } + await createUser({ username: newUsername, password: newPassword, email: newEmail, role: newRole }); + newUsername = ''; newPassword = ''; newEmail = ''; newRole = 'viewer'; + await loadUsers(); + message = $t('settingsAuth.userCreated'); + } catch (err: unknown) { + error = err instanceof ApiError ? err.message : $t('settingsAuth.createFailed'); + } } - async function deleteUser(id: string) { + async function handleDeleteUser(id: string) { + if (!confirm($t('settingsAuth.deleteConfirm'))) 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 = $t('settingsAuth.userDeleted'); } - else error = envelope.error ?? $t('settingsAuth.deleteFailed'); - } catch (err: unknown) { error = err instanceof Error ? err.message : $t('settingsAuth.networkError'); } + await apiDeleteUser(id); + await loadUsers(); + message = $t('settingsAuth.userDeleted'); + } catch (err: unknown) { + error = err instanceof ApiError ? err.message : $t('settingsAuth.deleteFailed'); + } }
+ {#if loading} +
+ + + +
+ {:else}

{$t('settingsAuth.title')}

{$t('settingsAuth.description')}

@@ -167,7 +197,7 @@ {user.created_at} - @@ -199,18 +229,5 @@
+ {/if}
- - { - const user = userDeleteTarget; - userDeleteTarget = null; - if (user) await deleteUser(user.id); - }} - oncancel={() => { userDeleteTarget = null; }} -/>