Add expiring panel auth sessions
This commit is contained in:
@@ -26,6 +26,14 @@ Default panel credentials:
|
|||||||
- login: `admin`
|
- login: `admin`
|
||||||
- password: `proxy-ui-demo`
|
- 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
|
## Docker run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ services:
|
|||||||
AUTO_START_3PROXY: "true"
|
AUTO_START_3PROXY: "true"
|
||||||
THREEPROXY_BINARY: "/usr/local/bin/3proxy"
|
THREEPROXY_BINARY: "/usr/local/bin/3proxy"
|
||||||
RUNTIME_DIR: "/app/runtime"
|
RUNTIME_DIR: "/app/runtime"
|
||||||
|
PANEL_AUTH_LOGIN: "admin"
|
||||||
|
PANEL_AUTH_PASSWORD: "proxy-ui-demo"
|
||||||
|
PANEL_SESSION_TTL_HOURS: "24"
|
||||||
volumes:
|
volumes:
|
||||||
- 3proxy-runtime:/app/runtime
|
- 3proxy-runtime:/app/runtime
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ Updated: 2026-04-02
|
|||||||
14. Added backend tests for config rendering and user-management API edge cases.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Updated: 2026-04-02
|
|||||||
- `000_START_HERE.md`: copy-ready continuation prompt for the next agent session
|
- `000_START_HERE.md`: copy-ready continuation prompt for the next agent session
|
||||||
- `.dockerignore`: trims Docker build context to runtime-relevant files only
|
- `.dockerignore`: trims Docker build context to runtime-relevant files only
|
||||||
- `AGENTS.md`: repository workflow rules for autonomous contributors
|
- `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
|
- `Dockerfile`: multi-stage image that builds the panel and compiles 3proxy
|
||||||
- `README.md`: quick start and current project scope
|
- `README.md`: quick start and current project scope
|
||||||
- `package.json`: frontend scripts and dependencies
|
- `package.json`: frontend scripts and dependencies
|
||||||
@@ -23,7 +23,7 @@ Updated: 2026-04-02
|
|||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
- `src/main.tsx`: application bootstrap
|
- `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/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.test.tsx`: login-gate, modal interaction, pause/resume, delete-confirm, and system-save UI tests
|
||||||
- `src/app.css`: full panel styling
|
- `src/app.css`: full panel styling
|
||||||
@@ -37,8 +37,9 @@ Updated: 2026-04-02
|
|||||||
## Server
|
## Server
|
||||||
|
|
||||||
- `server/index.ts`: backend entrypoint and runtime bootstrap
|
- `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/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.ts`: 3proxy config renderer, validation, and dashboard derivation
|
||||||
- `server/lib/config.test.ts`: config-generation regression tests
|
- `server/lib/config.test.ts`: config-generation regression tests
|
||||||
- `server/lib/runtime.ts`: managed 3proxy process controller
|
- `server/lib/runtime.ts`: managed 3proxy process controller
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import request from 'supertest';
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import type { UpdateSystemInput } from '../src/shared/contracts';
|
import type { UpdateSystemInput } from '../src/shared/contracts';
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
|
import { AuthService } from './lib/auth';
|
||||||
import type { RuntimeSnapshot } from './lib/config';
|
import type { RuntimeSnapshot } from './lib/config';
|
||||||
import type { RuntimeController } from './lib/runtime';
|
import type { RuntimeController } from './lib/runtime';
|
||||||
import { StateStore } from './lib/store';
|
import { StateStore } from './lib/store';
|
||||||
@@ -47,9 +48,31 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('panel api', () => {
|
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 () => {
|
it('rejects user creation against a non-assignable service', async () => {
|
||||||
const app = await createTestApp();
|
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',
|
username: 'bad-admin-user',
|
||||||
password: 'secret123',
|
password: 'secret123',
|
||||||
serviceId: 'admin',
|
serviceId: 'admin',
|
||||||
@@ -62,15 +85,16 @@ describe('panel api', () => {
|
|||||||
|
|
||||||
it('pauses and deletes a user through the api', async () => {
|
it('pauses and deletes a user through the api', async () => {
|
||||||
const app = await createTestApp();
|
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 userId = initial.body.userRecords[0].id;
|
||||||
const username = initial.body.userRecords[0].username;
|
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.status).toBe(200);
|
||||||
expect(paused.body.userRecords.find((entry: { id: string }) => entry.id === userId).paused).toBe(true);
|
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.status).toBe(200);
|
||||||
expect(removed.body.userRecords.some((entry: { username: string }) => entry.username === username)).toBe(
|
expect(removed.body.userRecords.some((entry: { username: string }) => entry.username === username)).toBe(
|
||||||
false,
|
false,
|
||||||
@@ -79,12 +103,13 @@ describe('panel api', () => {
|
|||||||
|
|
||||||
it('rejects system updates when two services reuse the same port', async () => {
|
it('rejects system updates when two services reuse the same port', async () => {
|
||||||
const app = await createTestApp();
|
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);
|
const system = createSystemPayload(initial.body);
|
||||||
|
|
||||||
system.services[1].port = system.services[0].port;
|
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.status).toBe(400);
|
||||||
expect(response.body.error).toMatch(/cannot share port/i);
|
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 () => {
|
it('rejects system updates that strand existing users on a disabled service', async () => {
|
||||||
const app = await createTestApp();
|
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);
|
const system = createSystemPayload(initial.body);
|
||||||
|
|
||||||
system.services = system.services.map((service) =>
|
system.services = system.services.map((service) =>
|
||||||
service.id === 'socks-main' ? { ...service, enabled: false } : 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.status).toBe(400);
|
||||||
expect(response.body.error).toMatch(/enabled assignable service/i);
|
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 () => {
|
it('updates system settings and regenerates the rendered config', async () => {
|
||||||
const app = await createTestApp();
|
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);
|
const system = createSystemPayload(initial.body);
|
||||||
|
|
||||||
system.publicHost = 'ops-gateway.example.net';
|
system.publicHost = 'ops-gateway.example.net';
|
||||||
@@ -116,7 +143,7 @@ describe('panel api', () => {
|
|||||||
service.id === 'socks-main' ? { ...service, port: 1180 } : service,
|
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.status).toBe(200);
|
||||||
expect(response.body.system.publicHost).toBe('ops-gateway.example.net');
|
expect(response.body.system.publicHost).toBe('ops-gateway.example.net');
|
||||||
@@ -134,11 +161,17 @@ async function createTestApp() {
|
|||||||
|
|
||||||
const runtime = new FakeRuntime();
|
const runtime = new FakeRuntime();
|
||||||
const store = new StateStore(path.join(dir, 'state', 'panel-state.json'));
|
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({
|
return createApp({
|
||||||
store,
|
store,
|
||||||
runtime,
|
runtime,
|
||||||
runtimeRootDir: dir,
|
runtimeRootDir: dir,
|
||||||
|
auth,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,3 +179,12 @@ function createSystemPayload(body: { system: Record<string, unknown> }): UpdateS
|
|||||||
const { previewConfig: _previewConfig, ...system } = body.system;
|
const { previewConfig: _previewConfig, ...system } = body.system;
|
||||||
return structuredClone(system) as unknown as UpdateSystemInput;
|
return structuredClone(system) as unknown as UpdateSystemInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function authorize(app: Awaited<ReturnType<typeof createTestApp>>) {
|
||||||
|
const response = await request(app).post('/api/auth/login').send({
|
||||||
|
login: 'admin',
|
||||||
|
password: 'proxy-ui-demo',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.body.token as string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import express, { type Request, type Response } from 'express';
|
import express, { type Request, type Response } from 'express';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
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 { validateCreateUserInput, validateSystemInput } from '../src/shared/validation';
|
||||||
import {
|
import {
|
||||||
buildRuntimePaths,
|
buildRuntimePaths,
|
||||||
@@ -10,6 +15,7 @@ import {
|
|||||||
render3proxyConfig,
|
render3proxyConfig,
|
||||||
type RuntimePaths,
|
type RuntimePaths,
|
||||||
} from './lib/config';
|
} from './lib/config';
|
||||||
|
import { AuthService } from './lib/auth';
|
||||||
import type { RuntimeController } from './lib/runtime';
|
import type { RuntimeController } from './lib/runtime';
|
||||||
import { StateStore } from './lib/store';
|
import { StateStore } from './lib/store';
|
||||||
|
|
||||||
@@ -17,9 +23,10 @@ export interface AppServices {
|
|||||||
store: StateStore;
|
store: StateStore;
|
||||||
runtime: RuntimeController;
|
runtime: RuntimeController;
|
||||||
runtimeRootDir: string;
|
runtimeRootDir: string;
|
||||||
|
auth: AuthService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp({ store, runtime, runtimeRootDir }: AppServices) {
|
export function createApp({ store, runtime, runtimeRootDir, auth }: AppServices) {
|
||||||
const app = express();
|
const app = express();
|
||||||
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
||||||
const distDir = path.resolve('dist');
|
const distDir = path.resolve('dist');
|
||||||
@@ -27,6 +34,40 @@ export function createApp({ store, runtime, runtimeRootDir }: AppServices) {
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(distDir));
|
app.use(express.static(distDir));
|
||||||
|
|
||||||
|
app.post('/api/auth/login', (request, response) => {
|
||||||
|
const input = request.body as Partial<PanelLoginInput>;
|
||||||
|
|
||||||
|
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) => {
|
app.get('/api/health', async (_request, response) => {
|
||||||
const state = await store.read();
|
const state = await store.read();
|
||||||
const previewConfig = render3proxyConfig(state, runtimePaths);
|
const previewConfig = render3proxyConfig(state, runtimePaths);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
|
import { AuthService } from './lib/auth';
|
||||||
import { buildRuntimePaths, render3proxyConfig } from './lib/config';
|
import { buildRuntimePaths, render3proxyConfig } from './lib/config';
|
||||||
import { ThreeProxyManager } from './lib/runtime';
|
import { ThreeProxyManager } from './lib/runtime';
|
||||||
import { StateStore } from './lib/store';
|
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 statePath = path.join(runtimeRootDir, 'state', 'panel-state.json');
|
||||||
const binaryPath = path.resolve(process.env.THREEPROXY_BINARY ?? '/usr/local/bin/3proxy');
|
const binaryPath = path.resolve(process.env.THREEPROXY_BINARY ?? '/usr/local/bin/3proxy');
|
||||||
const autoStart = process.env.AUTO_START_3PROXY === 'true';
|
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 store = new StateStore(statePath);
|
||||||
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
||||||
const runtime = new ThreeProxyManager(binaryPath, runtimePaths.configPath, runtimePaths, autoStart);
|
const runtime = new ThreeProxyManager(binaryPath, runtimePaths.configPath, runtimePaths, autoStart);
|
||||||
|
const auth = new AuthService({
|
||||||
|
login: authLogin,
|
||||||
|
password: authPassword,
|
||||||
|
ttlMs: sessionTtlMs,
|
||||||
|
secret: authSecret,
|
||||||
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const initialState = await store.read();
|
const initialState = await store.read();
|
||||||
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
|
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
|
||||||
await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8');
|
await fs.writeFile(runtimePaths.configPath, render3proxyConfig(initialState, runtimePaths), 'utf8');
|
||||||
await runtime.initialize();
|
await runtime.initialize();
|
||||||
const app = createApp({ store, runtime, runtimeRootDir });
|
const app = createApp({ store, runtime, runtimeRootDir, auth });
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Panel server listening on http://0.0.0.0:${port}`);
|
console.log(`Panel server listening on http://0.0.0.0:${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
112
server/lib/auth.ts
Normal file
112
server/lib/auth.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { render, screen, within } from '@testing-library/react';
|
import { render, screen, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
|
async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
|
||||||
@@ -9,6 +9,10 @@ async function loginIntoPanel(user: ReturnType<typeof userEvent.setup>) {
|
|||||||
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
await user.click(screen.getByRole('button', { name: /open panel/i }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
describe('App login gate', () => {
|
describe('App login gate', () => {
|
||||||
it('rejects wrong hardcoded credentials and keeps the panel locked', async () => {
|
it('rejects wrong hardcoded credentials and keeps the panel locked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -32,6 +36,20 @@ describe('App login gate', () => {
|
|||||||
expect(screen.getByRole('heading', { name: /3proxy ui/i })).toBeInTheDocument();
|
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(<App />);
|
||||||
|
|
||||||
|
await loginIntoPanel(user);
|
||||||
|
expect(screen.getByRole('navigation', { name: /primary/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
firstRender.unmount();
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
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 () => {
|
it('opens add-user flow in a modal and closes it on escape', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|||||||
322
src/App.tsx
322
src/App.tsx
@@ -12,6 +12,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CreateUserInput,
|
CreateUserInput,
|
||||||
DashboardSnapshot,
|
DashboardSnapshot,
|
||||||
|
PanelLoginResponse,
|
||||||
ProxyServiceRecord,
|
ProxyServiceRecord,
|
||||||
ProxyUserRecord,
|
ProxyUserRecord,
|
||||||
UpdateSystemInput,
|
UpdateSystemInput,
|
||||||
@@ -27,21 +28,32 @@ const tabs: Array<{ id: TabId; label: string }> = [
|
|||||||
{ id: 'system', label: 'System' },
|
{ 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<void> }) {
|
||||||
const [login, setLogin] = useState('');
|
const [login, setLogin] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
if (login === panelAuth.login && password === panelAuth.password) {
|
try {
|
||||||
|
await onUnlock(login, password);
|
||||||
setError('');
|
setError('');
|
||||||
onUnlock();
|
} catch (submitError) {
|
||||||
return;
|
setError(submitError instanceof Error ? submitError.message : 'Unable to open the panel.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('Wrong panel credentials. Check the hardcoded startup values.');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,6 +69,7 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) {
|
|||||||
<input
|
<input
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
name="login"
|
name="login"
|
||||||
|
disabled={isSubmitting}
|
||||||
value={login}
|
value={login}
|
||||||
onChange={(event) => setLogin(event.target.value)}
|
onChange={(event) => setLogin(event.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -67,11 +80,14 @@ function LoginGate({ onUnlock }: { onUnlock: () => void }) {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
disabled={isSubmitting}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Open panel</button>
|
<button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Opening...' : 'Open panel'}
|
||||||
|
</button>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -510,31 +526,86 @@ function UsersTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [isAuthed, setIsAuthed] = useState(false);
|
const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession());
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
||||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(fallbackDashboardSnapshot);
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot>(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(() => {
|
useEffect(() => {
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void fetch('/api/state')
|
void requestSnapshot(() =>
|
||||||
.then((response) => (response.ok ? response.json() : Promise.reject(new Error('API unavailable'))))
|
fetch('/api/state', {
|
||||||
.then((payload: DashboardSnapshot) => {
|
headers: buildAuthHeaders(session.token),
|
||||||
if (!cancelled) {
|
}),
|
||||||
|
)
|
||||||
|
.then((payload) => {
|
||||||
|
if (!cancelled && payload) {
|
||||||
setSnapshot(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.
|
// Keep fallback snapshot for local UI and tests when backend is not running.
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [session]);
|
||||||
|
|
||||||
if (!isAuthed) {
|
if (!session) {
|
||||||
return <LoginGate onUnlock={() => setIsAuthed(true)} />;
|
return <LoginGate onUnlock={handleUnlock} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mutateSnapshot = async (
|
const mutateSnapshot = async (
|
||||||
@@ -542,13 +613,17 @@ export default function App() {
|
|||||||
fallback: (current: DashboardSnapshot) => DashboardSnapshot,
|
fallback: (current: DashboardSnapshot) => DashboardSnapshot,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await request();
|
const payload = await requestSnapshot(request);
|
||||||
if (response.ok) {
|
if (payload) {
|
||||||
const payload = (await response.json()) as DashboardSnapshot;
|
|
||||||
setSnapshot(payload);
|
setSnapshot(payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (error instanceof SessionExpiredError) {
|
||||||
|
resetSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to local optimistic state when the API is unavailable.
|
// 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') => {
|
const handleRuntimeAction = async (action: 'start' | 'restart') => {
|
||||||
await mutateSnapshot(
|
await mutateSnapshot(
|
||||||
() => fetch(`/api/runtime/${action}`, { method: 'POST' }),
|
() =>
|
||||||
|
fetch(`/api/runtime/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildAuthHeaders(session.token),
|
||||||
|
}),
|
||||||
(current) =>
|
(current) =>
|
||||||
withDerivedSnapshot({
|
withDerivedSnapshot({
|
||||||
...current,
|
...current,
|
||||||
@@ -577,45 +656,62 @@ export default function App() {
|
|||||||
throw new Error('Username already exists.');
|
throw new Error('Username already exists.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await requestSnapshot(() =>
|
let payload: DashboardSnapshot | null;
|
||||||
fetch('/api/users', {
|
try {
|
||||||
method: 'POST',
|
payload = await requestSnapshot(() =>
|
||||||
headers: { 'Content-Type': 'application/json' },
|
fetch('/api/users', {
|
||||||
body: JSON.stringify(validated),
|
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) {
|
throw error;
|
||||||
setSnapshot(payload);
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUser: ProxyUserRecord = {
|
setSnapshot(payload);
|
||||||
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],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTogglePause = async (userId: string) => {
|
const handleTogglePause = async (userId: string) => {
|
||||||
await mutateSnapshot(
|
await mutateSnapshot(
|
||||||
() => fetch(`/api/users/${userId}/pause`, { method: 'POST' }),
|
() =>
|
||||||
|
fetch(`/api/users/${userId}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildAuthHeaders(session.token),
|
||||||
|
}),
|
||||||
(current) =>
|
(current) =>
|
||||||
withDerivedSnapshot({
|
withDerivedSnapshot({
|
||||||
...current,
|
...current,
|
||||||
@@ -628,7 +724,11 @@ export default function App() {
|
|||||||
|
|
||||||
const handleDeleteUser = async (userId: string) => {
|
const handleDeleteUser = async (userId: string) => {
|
||||||
await mutateSnapshot(
|
await mutateSnapshot(
|
||||||
() => fetch(`/api/users/${userId}`, { method: 'DELETE' }),
|
() =>
|
||||||
|
fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: buildAuthHeaders(session.token),
|
||||||
|
}),
|
||||||
(current) =>
|
(current) =>
|
||||||
withDerivedSnapshot({
|
withDerivedSnapshot({
|
||||||
...current,
|
...current,
|
||||||
@@ -638,32 +738,45 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveSystem = async (input: UpdateSystemInput) => {
|
const handleSaveSystem = async (input: UpdateSystemInput) => {
|
||||||
const payload = await requestSnapshot(() =>
|
let payload: DashboardSnapshot | null;
|
||||||
fetch('/api/system', {
|
try {
|
||||||
method: 'PUT',
|
payload = await requestSnapshot(() =>
|
||||||
headers: { 'Content-Type': 'application/json' },
|
fetch('/api/system', {
|
||||||
body: JSON.stringify(input),
|
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) {
|
throw error;
|
||||||
setSnapshot(payload);
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
setSnapshot((current) =>
|
||||||
|
withDerivedSnapshot({
|
||||||
|
...current,
|
||||||
|
service: {
|
||||||
|
...current.service,
|
||||||
|
lastEvent: 'System configuration updated from panel',
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
...input,
|
||||||
|
previewConfig: current.system.previewConfig,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSnapshot((current) =>
|
setSnapshot(payload);
|
||||||
withDerivedSnapshot({
|
|
||||||
...current,
|
|
||||||
service: {
|
|
||||||
...current.service,
|
|
||||||
lastEvent: 'System configuration updated from panel',
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
...input,
|
|
||||||
previewConfig: current.system.previewConfig,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -686,6 +799,13 @@ export default function App() {
|
|||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
<strong>{snapshot.users.total}</strong>
|
<strong>{snapshot.users.total}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-secondary"
|
||||||
|
onClick={resetSession}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -748,6 +868,10 @@ async function requestSnapshot(request: () => Promise<Response>): Promise<Dashbo
|
|||||||
try {
|
try {
|
||||||
const response = await request();
|
const response = await request();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new SessionExpiredError(await readApiError(response));
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await readApiError(response));
|
throw new Error(await readApiError(response));
|
||||||
}
|
}
|
||||||
@@ -774,3 +898,49 @@ async function readApiError(response: Response): Promise<string> {
|
|||||||
|
|
||||||
return `Request failed with status ${response.status}.`;
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,3 +89,14 @@ export interface CreateUserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateSystemInput = SystemSettings;
|
export type UpdateSystemInput = SystemSettings;
|
||||||
|
|
||||||
|
export interface PanelLoginInput {
|
||||||
|
login: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelLoginResponse {
|
||||||
|
token: string;
|
||||||
|
expiresAt: string;
|
||||||
|
ttlMs: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user