Add expiring panel auth sessions
This commit is contained in:
@@ -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 />);
|
||||
|
||||
322
src/App.tsx
322
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<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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user