Add expiring panel auth sessions

This commit is contained in:
2026-04-02 00:45:27 +03:00
parent e342693211
commit 69c97ea387
11 changed files with 514 additions and 93 deletions

View File

@@ -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<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 }));
}
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(<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 () => {
const user = userEvent.setup();
render(<App />);

View File

@@ -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<void> }) {
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
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 }) {
<input
autoComplete="username"
name="login"
disabled={isSubmitting}
value={login}
onChange={(event) => 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)}
/>
</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}
</form>
</section>
@@ -510,31 +526,86 @@ function UsersTab({
}
export default function App() {
const [isAuthed, setIsAuthed] = useState(false);
const [session, setSession] = useState<StoredSession | null>(() => loadStoredSession());
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
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(() => {
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 <LoginGate onUnlock={() => setIsAuthed(true)} />;
if (!session) {
return <LoginGate onUnlock={handleUnlock} />;
}
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() {
<span>Users</span>
<strong>{snapshot.users.total}</strong>
</div>
<button
type="button"
className="button-secondary"
onClick={resetSession}
>
Sign out
</button>
</div>
</header>
@@ -748,6 +868,10 @@ async function requestSnapshot(request: () => Promise<Response>): Promise<Dashbo
try {
const response = await request();
if (response.status === 401) {
throw new SessionExpiredError(await readApiError(response));
}
if (!response.ok) {
throw new Error(await readApiError(response));
}
@@ -774,3 +898,49 @@ async function readApiError(response: Response): Promise<string> {
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(),
};
}

View File

@@ -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;
}