From 69c97ea387fc6e7d8ed8bac45fc4f431b4e9b2ff Mon Sep 17 00:00:00 2001 From: rednakse Date: Thu, 2 Apr 2026 00:45:27 +0300 Subject: [PATCH] Add expiring panel auth sessions --- README.md | 8 + compose.yaml | 3 + docs/PLAN.md | 1 + docs/PROJECT_INDEX.md | 7 +- server/app.test.ts | 62 ++++++-- server/app.ts | 45 +++++- server/index.ts | 16 +- server/lib/auth.ts | 112 ++++++++++++++ src/App.test.tsx | 20 ++- src/App.tsx | 322 ++++++++++++++++++++++++++++++---------- src/shared/contracts.ts | 11 ++ 11 files changed, 514 insertions(+), 93 deletions(-) create mode 100644 server/lib/auth.ts diff --git a/README.md b/README.md index 4718808..8970cf3 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ Default panel credentials: - login: `admin` - password: `proxy-ui-demo` +For Docker runs these values come from `compose.yaml`: + +- `PANEL_AUTH_LOGIN` +- `PANEL_AUTH_PASSWORD` +- `PANEL_SESSION_TTL_HOURS` with a default of `24` + +The panel stores the issued session token in `sessionStorage`, so a browser refresh keeps the operator signed in until the token expires. + ## Docker run ```bash diff --git a/compose.yaml b/compose.yaml index a1e8cc3..4518726 100644 --- a/compose.yaml +++ b/compose.yaml @@ -15,6 +15,9 @@ services: AUTO_START_3PROXY: "true" THREEPROXY_BINARY: "/usr/local/bin/3proxy" RUNTIME_DIR: "/app/runtime" + PANEL_AUTH_LOGIN: "admin" + PANEL_AUTH_PASSWORD: "proxy-ui-demo" + PANEL_SESSION_TTL_HOURS: "24" volumes: - 3proxy-runtime:/app/runtime restart: unless-stopped diff --git a/docs/PLAN.md b/docs/PLAN.md index be3b5ac..49c39da 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -30,3 +30,4 @@ Updated: 2026-04-02 14. Added backend tests for config rendering and user-management API edge cases. 15. Added editable System settings with shared validation, a system update API, service/port conflict protection, and UI coverage for local save flows. 16. Simplified the System tab layout by removing the redundant Runtime card and collapsing those fields into a compact settings section. +17. Moved panel auth to server-issued expiring tokens with `sessionStorage` persistence and Compose-configurable credentials/TTL. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index d89ffdf..35180df 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -7,7 +7,7 @@ Updated: 2026-04-02 - `000_START_HERE.md`: copy-ready continuation prompt for the next agent session - `.dockerignore`: trims Docker build context to runtime-relevant files only - `AGENTS.md`: repository workflow rules for autonomous contributors -- `compose.yaml`: Docker Compose entrypoint for the bundled panel + 3proxy runtime +- `compose.yaml`: Docker Compose entrypoint for the bundled panel + 3proxy runtime, including panel auth env defaults - `Dockerfile`: multi-stage image that builds the panel and compiles 3proxy - `README.md`: quick start and current project scope - `package.json`: frontend scripts and dependencies @@ -23,7 +23,7 @@ Updated: 2026-04-02 ## Frontend - `src/main.tsx`: application bootstrap -- `src/App.tsx`: authenticated panel shell with API-backed dashboard/user flows and validated local fallback mutations +- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, and protected panel mutations - `src/SystemTab.tsx`: editable system settings and managed services form with compact panel-level controls - `src/App.test.tsx`: login-gate, modal interaction, pause/resume, delete-confirm, and system-save UI tests - `src/app.css`: full panel styling @@ -37,8 +37,9 @@ Updated: 2026-04-02 ## Server - `server/index.ts`: backend entrypoint and runtime bootstrap -- `server/app.ts`: Express app with panel state, runtime routes, and writable system configuration API +- `server/app.ts`: Express app with login, protected panel state/runtime routes, and writable system configuration API - `server/app.test.ts`: API tests for user management plus system-update safety edge cases +- `server/lib/auth.ts`: expiring token issuance and bearer-token verification for the panel - `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation - `server/lib/config.test.ts`: config-generation regression tests - `server/lib/runtime.ts`: managed 3proxy process controller diff --git a/server/app.test.ts b/server/app.test.ts index 4f33727..5c4005a 100644 --- a/server/app.test.ts +++ b/server/app.test.ts @@ -5,6 +5,7 @@ import request from 'supertest'; import { afterEach, describe, expect, it } from 'vitest'; import type { UpdateSystemInput } from '../src/shared/contracts'; import { createApp } from './app'; +import { AuthService } from './lib/auth'; import type { RuntimeSnapshot } from './lib/config'; import type { RuntimeController } from './lib/runtime'; import { StateStore } from './lib/store'; @@ -47,9 +48,31 @@ afterEach(async () => { }); describe('panel api', () => { + it('rejects protected api access without a bearer token', async () => { + const app = await createTestApp(); + const response = await request(app).get('/api/state'); + + expect(response.status).toBe(401); + expect(response.body.error).toMatch(/authorization required/i); + }); + + it('logs in and returns an expiring panel token', async () => { + const app = await createTestApp(); + const response = await request(app).post('/api/auth/login').send({ + login: 'admin', + password: 'proxy-ui-demo', + }); + + expect(response.status).toBe(200); + expect(response.body.token).toMatch(/\./); + expect(response.body.ttlMs).toBe(24 * 60 * 60 * 1000); + expect(typeof response.body.expiresAt).toBe('string'); + }); + it('rejects user creation against a non-assignable service', async () => { const app = await createTestApp(); - const response = await request(app).post('/api/users').send({ + const token = await authorize(app); + const response = await request(app).post('/api/users').set('Authorization', `Bearer ${token}`).send({ username: 'bad-admin-user', password: 'secret123', serviceId: 'admin', @@ -62,15 +85,16 @@ describe('panel api', () => { it('pauses and deletes a user through the api', async () => { const app = await createTestApp(); - const initial = await request(app).get('/api/state'); + const token = await authorize(app); + const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`); const userId = initial.body.userRecords[0].id; const username = initial.body.userRecords[0].username; - const paused = await request(app).post(`/api/users/${userId}/pause`); + const paused = await request(app).post(`/api/users/${userId}/pause`).set('Authorization', `Bearer ${token}`); expect(paused.status).toBe(200); expect(paused.body.userRecords.find((entry: { id: string }) => entry.id === userId).paused).toBe(true); - const removed = await request(app).delete(`/api/users/${userId}`); + const removed = await request(app).delete(`/api/users/${userId}`).set('Authorization', `Bearer ${token}`); expect(removed.status).toBe(200); expect(removed.body.userRecords.some((entry: { username: string }) => entry.username === username)).toBe( false, @@ -79,12 +103,13 @@ describe('panel api', () => { it('rejects system updates when two services reuse the same port', async () => { const app = await createTestApp(); - const initial = await request(app).get('/api/state'); + const token = await authorize(app); + const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`); const system = createSystemPayload(initial.body); system.services[1].port = system.services[0].port; - const response = await request(app).put('/api/system').send(system); + const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system); expect(response.status).toBe(400); expect(response.body.error).toMatch(/cannot share port/i); @@ -92,14 +117,15 @@ describe('panel api', () => { it('rejects system updates that strand existing users on a disabled service', async () => { const app = await createTestApp(); - const initial = await request(app).get('/api/state'); + const token = await authorize(app); + const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`); const system = createSystemPayload(initial.body); system.services = system.services.map((service) => service.id === 'socks-main' ? { ...service, enabled: false } : service, ); - const response = await request(app).put('/api/system').send(system); + const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system); expect(response.status).toBe(400); expect(response.body.error).toMatch(/enabled assignable service/i); @@ -108,7 +134,8 @@ describe('panel api', () => { it('updates system settings and regenerates the rendered config', async () => { const app = await createTestApp(); - const initial = await request(app).get('/api/state'); + const token = await authorize(app); + const initial = await request(app).get('/api/state').set('Authorization', `Bearer ${token}`); const system = createSystemPayload(initial.body); system.publicHost = 'ops-gateway.example.net'; @@ -116,7 +143,7 @@ describe('panel api', () => { service.id === 'socks-main' ? { ...service, port: 1180 } : service, ); - const response = await request(app).put('/api/system').send(system); + const response = await request(app).put('/api/system').set('Authorization', `Bearer ${token}`).send(system); expect(response.status).toBe(200); expect(response.body.system.publicHost).toBe('ops-gateway.example.net'); @@ -134,11 +161,17 @@ async function createTestApp() { const runtime = new FakeRuntime(); const store = new StateStore(path.join(dir, 'state', 'panel-state.json')); + const auth = new AuthService({ + login: 'admin', + password: 'proxy-ui-demo', + ttlMs: 24 * 60 * 60 * 1000, + }); return createApp({ store, runtime, runtimeRootDir: dir, + auth, }); } @@ -146,3 +179,12 @@ function createSystemPayload(body: { system: Record }): UpdateS const { previewConfig: _previewConfig, ...system } = body.system; return structuredClone(system) as unknown as UpdateSystemInput; } + +async function authorize(app: Awaited>) { + const response = await request(app).post('/api/auth/login').send({ + login: 'admin', + password: 'proxy-ui-demo', + }); + + return response.body.token as string; +} diff --git a/server/app.ts b/server/app.ts index 6598f2a..58c125b 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,7 +1,12 @@ import express, { type Request, type Response } from 'express'; import fs from 'node:fs/promises'; import path from 'node:path'; -import type { ControlPlaneState, CreateUserInput, UpdateSystemInput } from '../src/shared/contracts'; +import type { + ControlPlaneState, + CreateUserInput, + PanelLoginInput, + UpdateSystemInput, +} from '../src/shared/contracts'; import { validateCreateUserInput, validateSystemInput } from '../src/shared/validation'; import { buildRuntimePaths, @@ -10,6 +15,7 @@ import { render3proxyConfig, type RuntimePaths, } from './lib/config'; +import { AuthService } from './lib/auth'; import type { RuntimeController } from './lib/runtime'; import { StateStore } from './lib/store'; @@ -17,9 +23,10 @@ export interface AppServices { store: StateStore; runtime: RuntimeController; runtimeRootDir: string; + auth: AuthService; } -export function createApp({ store, runtime, runtimeRootDir }: AppServices) { +export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices) { const app = express(); const runtimePaths = buildRuntimePaths(runtimeRootDir); const distDir = path.resolve('dist'); @@ -27,6 +34,40 @@ export function createApp({ store, runtime, runtimeRootDir }: AppServices) { app.use(express.json()); app.use(express.static(distDir)); + app.post('/api/auth/login', (request, response) => { + const input = request.body as Partial; + + try { + const payload = auth.login(input.login?.trim() ?? '', input.password ?? ''); + response.json(payload); + } catch (error) { + response.status(401).json({ + error: error instanceof Error ? error.message : 'Wrong panel credentials.', + }); + } + }); + + app.use('/api', (request, response, next) => { + if (request.path === '/auth/login' || request.path === '/health') { + next(); + return; + } + + const token = auth.extractBearerToken(request); + + if (!token) { + response.status(401).json(auth.unauthorizedError()); + return; + } + + if (!auth.verify(token)) { + response.status(401).json(auth.invalidTokenError()); + return; + } + + next(); + }); + app.get('/api/health', async (_request, response) => { const state = await store.read(); const previewConfig = render3proxyConfig(state, runtimePaths); diff --git a/server/index.ts b/server/index.ts index 65b7fdd..3b3feb7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import { createApp } from './app'; +import { AuthService } from './lib/auth'; import { buildRuntimePaths, render3proxyConfig } from './lib/config'; import { ThreeProxyManager } from './lib/runtime'; import { StateStore } from './lib/store'; @@ -10,17 +11,30 @@ const runtimeRootDir = path.resolve(process.env.RUNTIME_DIR ?? 'runtime'); const statePath = path.join(runtimeRootDir, 'state', 'panel-state.json'); const binaryPath = path.resolve(process.env.THREEPROXY_BINARY ?? '/usr/local/bin/3proxy'); const autoStart = process.env.AUTO_START_3PROXY === 'true'; +const authLogin = process.env.PANEL_AUTH_LOGIN ?? 'admin'; +const authPassword = process.env.PANEL_AUTH_PASSWORD ?? 'proxy-ui-demo'; +const sessionTtlHours = Number(process.env.PANEL_SESSION_TTL_HOURS ?? '24'); +const sessionTtlMs = Number.isFinite(sessionTtlHours) && sessionTtlHours > 0 + ? sessionTtlHours * 60 * 60 * 1000 + : 24 * 60 * 60 * 1000; +const authSecret = process.env.PANEL_TOKEN_SECRET; const store = new StateStore(statePath); const runtimePaths = buildRuntimePaths(runtimeRootDir); const runtime = new ThreeProxyManager(binaryPath, runtimePaths.configPath, runtimePaths, autoStart); +const auth = new AuthService({ + login: authLogin, + password: authPassword, + ttlMs: sessionTtlMs, + secret: authSecret, +}); async function main() { const initialState = await store.read(); await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true }); await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8'); await runtime.initialize(); - const app = createApp({ store, runtime, runtimeRootDir }); + const app = createApp({ store, runtime, runtimeRootDir, auth }); app.listen(port, () => { console.log(`Panel server listening on http://0.0.0.0:${port}`); }); diff --git a/server/lib/auth.ts b/server/lib/auth.ts new file mode 100644 index 0000000..339cd6e --- /dev/null +++ b/server/lib/auth.ts @@ -0,0 +1,112 @@ +import crypto from 'node:crypto'; +import type { Request } from 'express'; + +export interface AuthConfig { + login: string; + password: string; + ttlMs: number; + secret?: string; +} + +interface TokenPayload { + sub: string; + exp: number; +} + +export class AuthService { + private readonly secret: string; + + constructor(private readonly config: AuthConfig) { + this.secret = config.secret ?? `${config.login}:${config.password}`; + } + + login(login: string, password: string) { + if (!safeEqual(login, this.config.login) || !safeEqual(password, this.config.password)) { + throw new Error('Wrong panel credentials.'); + } + + const payload: TokenPayload = { + sub: this.config.login, + exp: Date.now() + this.config.ttlMs, + }; + + const encoded = encodeBase64Url(JSON.stringify(payload)); + const signature = this.sign(encoded); + + return { + token: `${encoded}.${signature}`, + expiresAt: new Date(payload.exp).toISOString(), + ttlMs: this.config.ttlMs, + }; + } + + verify(token: string): TokenPayload | null { + const [encoded, signature] = token.split('.'); + + if (!encoded || !signature || !safeEqual(this.sign(encoded), signature)) { + return null; + } + + try { + const payload = JSON.parse(decodeBase64Url(encoded)) as TokenPayload; + + if (payload.sub !== this.config.login || !Number.isFinite(payload.exp) || payload.exp <= Date.now()) { + return null; + } + + return payload; + } catch { + return null; + } + } + + extractBearerToken(request: Request): string | null { + const header = request.header('authorization'); + + if (!header) { + return null; + } + + const [scheme, token] = header.split(' '); + if (!scheme || !token || scheme.toLowerCase() !== 'bearer') { + return null; + } + + return token; + } + + unauthorizedError() { + return { error: 'Panel authorization required.' }; + } + + invalidTokenError() { + return { error: 'Panel session is missing or expired.' }; + } + + private sign(value: string): string { + return hmac(value, this.secret); + } +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function encodeBase64Url(value: string): string { + return Buffer.from(value, 'utf8').toString('base64url'); +} + +function decodeBase64Url(value: string): string { + return Buffer.from(value, 'base64url').toString('utf8'); +} + +function hmac(value: string, secret: string): string { + return crypto.createHmac('sha256', secret).update(value).digest('base64url'); +} diff --git a/src/App.test.tsx b/src/App.test.tsx index bc010c1..bd396eb 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,6 +1,6 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import App from './App'; async function loginIntoPanel(user: ReturnType) { @@ -9,6 +9,10 @@ async function loginIntoPanel(user: ReturnType) { await user.click(screen.getByRole('button', { name: /open panel/i })); } +beforeEach(() => { + window.sessionStorage.clear(); +}); + describe('App login gate', () => { it('rejects wrong hardcoded credentials and keeps the panel locked', async () => { const user = userEvent.setup(); @@ -32,6 +36,20 @@ describe('App login gate', () => { expect(screen.getByRole('heading', { name: /3proxy ui/i })).toBeInTheDocument(); }); + it('restores the panel session from sessionStorage after a remount', async () => { + const user = userEvent.setup(); + const firstRender = render(); + + await loginIntoPanel(user); + expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument(); + + firstRender.unmount(); + render(); + + expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open panel/i })).not.toBeInTheDocument(); + }); + it('opens add-user flow in a modal and closes it on escape', async () => { const user = userEvent.setup(); render(); diff --git a/src/App.tsx b/src/App.tsx index 9f8bb90..64deec2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { import type { CreateUserInput, DashboardSnapshot, + PanelLoginResponse, ProxyServiceRecord, ProxyUserRecord, UpdateSystemInput, @@ -27,21 +28,32 @@ const tabs: Array<{ id: TabId; label: string }> = [ { id: 'system', label: 'System' }, ]; -function LoginGate({ onUnlock }: { onUnlock: () => void }) { +const SESSION_KEY = '3proxy-ui-panel-session'; +const DEFAULT_SESSION_TTL_MS = 24 * 60 * 60 * 1000; + +interface StoredSession { + token: string; + expiresAt: string; +} + +function LoginGate({ onUnlock }: { onUnlock: (login: string, password: string) => Promise }) { const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); - const handleSubmit = (event: FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); + setIsSubmitting(true); - if (login === panelAuth.login && password === panelAuth.password) { + try { + await onUnlock(login, password); setError(''); - onUnlock(); - return; + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to open the panel.'); + } finally { + setIsSubmitting(false); } - - setError('Wrong panel credentials. Check the hardcoded startup values.'); }; return ( @@ -57,6 +69,7 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) { setLogin(event.target.value)} /> @@ -67,11 +80,14 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) { autoComplete="current-password" name="password" type="password" + disabled={isSubmitting} value={password} onChange={(event) => setPassword(event.target.value)} /> - + {error ?

{error}

: null} @@ -510,31 +526,86 @@ function UsersTab({ } export default function App() { - const [isAuthed, setIsAuthed] = useState(false); + const [session, setSession] = useState(() => loadStoredSession()); const [activeTab, setActiveTab] = useState('dashboard'); const [snapshot, setSnapshot] = useState(fallbackDashboardSnapshot); + const resetSession = () => { + clearStoredSession(); + setSession(null); + }; + + async function handleUnlock(login: string, password: string) { + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ login, password }), + }); + + if (!response.ok) { + throw new Error(await readApiError(response)); + } + + const payload = (await response.json()) as PanelLoginResponse; + const nextSession = { + token: payload.token, + expiresAt: payload.expiresAt, + }; + + storeSession(nextSession); + setSession(nextSession); + } catch (error) { + if (error instanceof TypeError) { + if (login === panelAuth.login && password === panelAuth.password) { + const nextSession = createLocalFallbackSession(); + storeSession(nextSession); + setSession(nextSession); + return; + } + + throw new Error('Wrong panel credentials. Check the configured values.'); + } + + throw error; + } + } + useEffect(() => { + if (!session) { + return; + } + let cancelled = false; - void fetch('/api/state') - .then((response) => (response.ok ? response.json() : Promise.reject(new Error('API unavailable')))) - .then((payload: DashboardSnapshot) => { - if (!cancelled) { + void requestSnapshot(() => + fetch('/api/state', { + headers: buildAuthHeaders(session.token), + }), + ) + .then((payload) => { + if (!cancelled && payload) { setSnapshot(payload); } }) - .catch(() => { + .catch((error) => { + if (error instanceof SessionExpiredError) { + if (!cancelled) { + resetSession(); + } + return; + } + // Keep fallback snapshot for local UI and tests when backend is not running. }); return () => { cancelled = true; }; - }, []); + }, [session]); - if (!isAuthed) { - return setIsAuthed(true)} />; + if (!session) { + return ; } const mutateSnapshot = async ( @@ -542,13 +613,17 @@ export default function App() { fallback: (current: DashboardSnapshot) => DashboardSnapshot, ) => { try { - const response = await request(); - if (response.ok) { - const payload = (await response.json()) as DashboardSnapshot; + const payload = await requestSnapshot(request); + if (payload) { setSnapshot(payload); return; } - } catch { + } catch (error) { + if (error instanceof SessionExpiredError) { + resetSession(); + return; + } + // Fall back to local optimistic state when the API is unavailable. } @@ -557,7 +632,11 @@ export default function App() { const handleRuntimeAction = async (action: 'start' | 'restart') => { await mutateSnapshot( - () => fetch(`/api/runtime/${action}`, { method: 'POST' }), + () => + fetch(`/api/runtime/${action}`, { + method: 'POST', + headers: buildAuthHeaders(session.token), + }), (current) => withDerivedSnapshot({ ...current, @@ -577,45 +656,62 @@ export default function App() { throw new Error('Username already exists.'); } - const payload = await requestSnapshot(() => - fetch('/api/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(validated), - }), - ); + let payload: DashboardSnapshot | null; + try { + payload = await requestSnapshot(() => + fetch('/api/users', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(session.token), + }, + body: JSON.stringify(validated), + }), + ); + } catch (error) { + if (error instanceof SessionExpiredError) { + resetSession(); + throw new Error('Panel session expired. Sign in again.'); + } - if (payload) { - setSnapshot(payload); + throw error; + } + + if (!payload) { + const nextUser: ProxyUserRecord = { + id: `u-${Math.random().toString(36).slice(2, 10)}`, + username: validated.username, + password: validated.password, + serviceId: validated.serviceId, + status: 'idle', + paused: false, + usedBytes: 0, + quotaBytes: quotaMbToBytes(validated.quotaMb), + }; + + setSnapshot((current) => + withDerivedSnapshot({ + ...current, + service: { + ...current.service, + lastEvent: `User ${nextUser.username} created from panel`, + }, + userRecords: [...current.userRecords, nextUser], + }), + ); return; } - const nextUser: ProxyUserRecord = { - id: `u-${Math.random().toString(36).slice(2, 10)}`, - username: validated.username, - password: validated.password, - serviceId: validated.serviceId, - status: 'idle', - paused: false, - usedBytes: 0, - quotaBytes: quotaMbToBytes(validated.quotaMb), - }; - - setSnapshot((current) => - withDerivedSnapshot({ - ...current, - service: { - ...current.service, - lastEvent: `User ${nextUser.username} created from panel`, - }, - userRecords: [...current.userRecords, nextUser], - }), - ); + setSnapshot(payload); }; const handleTogglePause = async (userId: string) => { await mutateSnapshot( - () => fetch(`/api/users/${userId}/pause`, { method: 'POST' }), + () => + fetch(`/api/users/${userId}/pause`, { + method: 'POST', + headers: buildAuthHeaders(session.token), + }), (current) => withDerivedSnapshot({ ...current, @@ -628,7 +724,11 @@ export default function App() { const handleDeleteUser = async (userId: string) => { await mutateSnapshot( - () => fetch(`/api/users/${userId}`, { method: 'DELETE' }), + () => + fetch(`/api/users/${userId}`, { + method: 'DELETE', + headers: buildAuthHeaders(session.token), + }), (current) => withDerivedSnapshot({ ...current, @@ -638,32 +738,45 @@ export default function App() { }; const handleSaveSystem = async (input: UpdateSystemInput) => { - const payload = await requestSnapshot(() => - fetch('/api/system', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }), - ); + let payload: DashboardSnapshot | null; + try { + payload = await requestSnapshot(() => + fetch('/api/system', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...buildAuthHeaders(session.token), + }, + body: JSON.stringify(input), + }), + ); + } catch (error) { + if (error instanceof SessionExpiredError) { + resetSession(); + throw new Error('Panel session expired. Sign in again.'); + } - if (payload) { - setSnapshot(payload); + throw error; + } + + if (!payload) { + setSnapshot((current) => + withDerivedSnapshot({ + ...current, + service: { + ...current.service, + lastEvent: 'System configuration updated from panel', + }, + system: { + ...input, + previewConfig: current.system.previewConfig, + }, + }), + ); return; } - setSnapshot((current) => - withDerivedSnapshot({ - ...current, - service: { - ...current.service, - lastEvent: 'System configuration updated from panel', - }, - system: { - ...input, - previewConfig: current.system.previewConfig, - }, - }), - ); + setSnapshot(payload); }; return ( @@ -686,6 +799,13 @@ export default function App() { Users {snapshot.users.total} + @@ -748,6 +868,10 @@ async function requestSnapshot(request: () => Promise): Promise { return `Request failed with status ${response.status}.`; } + +class SessionExpiredError extends Error {} + +function buildAuthHeaders(token: string): HeadersInit { + return { + Authorization: `Bearer ${token}`, + }; +} + +function loadStoredSession(): StoredSession | null { + try { + const raw = window.sessionStorage.getItem(SESSION_KEY); + + if (!raw) { + return null; + } + + const payload = JSON.parse(raw) as StoredSession; + const expiresAt = new Date(payload.expiresAt).getTime(); + + if (!payload.token || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + clearStoredSession(); + return null; + } + + return payload; + } catch { + clearStoredSession(); + return null; + } +} + +function storeSession(session: StoredSession): void { + window.sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)); +} + +function clearStoredSession(): void { + window.sessionStorage.removeItem(SESSION_KEY); +} + +function createLocalFallbackSession(): StoredSession { + return { + token: 'local-ui-fallback', + expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS).toISOString(), + }; +} diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index b8d1f77..f90c22a 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -89,3 +89,14 @@ export interface CreateUserInput { } export type UpdateSystemInput = SystemSettings; + +export interface PanelLoginInput { + login: string; + password: string; +} + +export interface PanelLoginResponse { + token: string; + expiresAt: string; + ttlMs: number; +}