import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
type ReactNode,
} from 'react';
import { ApiClient, ApiError, api as defaultApi } from '../lib/api';
import { ModalPortal } from '../components/modal-portal';
import type {
ConnectionState,
FeatureFlags,
OperationStatus,
ServerStatus,
SessionInfo,
StreamEvent,
} from '../types';
interface ServerContextValue {
api: ApiClient;
connection: ConnectionState;
status?: ServerStatus;
session?: SessionInfo;
features?: FeatureFlags;
lastError?: string;
lastEventAt?: Date;
events: StreamEvent[];
operations: OperationStatus[];
revisions: Readonly<Record<string, number>>;
refresh: () => Promise<void>;
setup: (bootstrapToken: string, username: string, password: string) => Promise<void>;
login: (username: string, password: string, secondFactor?: string, recovery?: boolean) => Promise<void>;
logout: () => Promise<void>;
}
const ServerContext = createContext<ServerContextValue | null>(null);
function saveCsrf(session?: SessionInfo): void {
if (session?.csrfToken) sessionStorage.setItem('xfesm.csrf', session.csrfToken);
else sessionStorage.removeItem('xfesm.csrf');
}
export function ServerProvider({ children, client = defaultApi }: { children: ReactNode; client?: ApiClient }) {
const [connection, setConnection] = useState<ConnectionState>('connecting');
const [status, setStatus] = useState<ServerStatus>();
const [session, setSession] = useState<SessionInfo>();
const [features, setFeatures] = useState<FeatureFlags>();
const [lastError, setLastError] = useState<string>();
const [lastEventAt, setLastEventAt] = useState<Date>();
const [events, setEvents] = useState<StreamEvent[]>([]);
const [operations, setOperations] = useState<OperationStatus[]>([]);
const [revisions, setRevisions] = useState<Record<string, number>>({});
const mounted = useRef(true);
const [reauthenticationOpen, setReauthenticationOpen] = useState(false);
const [reauthenticationCode, setReauthenticationCode] = useState('');
const [reauthenticationError, setReauthenticationError] = useState<string>();
const [reauthenticationBusy, setReauthenticationBusy] = useState(false);
const reauthenticationPromise = useRef<Promise<void> | undefined>(undefined);
const reauthenticationResolve = useRef<(() => void) | undefined>(undefined);
const reauthenticationReject = useRef<((error: Error) => void) | undefined>(undefined);
const refresh = useCallback(async () => {
setConnection((current) => (current === 'offline' ? 'connecting' : current));
try {
let nextSession: SessionInfo;
try {
nextSession = await client.session();
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) throw error;
const meta = await client.meta();
nextSession = { authenticated: false, bootstrapRequired: meta.setupRequired };
}
if (!mounted.current) return;
setSession(nextSession);
saveCsrf(nextSession);
if (!nextSession.authenticated) {
setStatus(undefined);
setFeatures(undefined);
setConnection('online');
setLastError(undefined);
return;
}
const [nextStatus, nextFeatures] = await Promise.all([client.status(), client.features()]);
if (!mounted.current) return;
setStatus(nextStatus);
setFeatures(nextFeatures);
setConnection('online');
setLastError(undefined);
} catch (error) {
if (!mounted.current) return;
const apiError = error instanceof ApiError ? error : undefined;
setConnection(apiError?.status === 401 || apiError?.status === 403 ? 'degraded' : 'offline');
setLastError(error instanceof Error ? error.message : String(error));
if (apiError?.status === 401) {
const unauthenticated: SessionInfo = { authenticated: false, bootstrapRequired: false };
setSession(unauthenticated);
saveCsrf(unauthenticated);
}
}
}, [client]);
useEffect(() => {
mounted.current = true;
void refresh();
const timer = globalThis.setInterval(() => void refresh(), 10_000);
return () => {
mounted.current = false;
globalThis.clearInterval(timer);
};
}, [refresh]);
useEffect(() => {
client.setReauthenticationHandler(() => {
if (reauthenticationPromise.current) return reauthenticationPromise.current;
setReauthenticationCode('');
setReauthenticationError(undefined);
setReauthenticationOpen(true);
const pending = new Promise<void>((resolve, reject) => {
reauthenticationResolve.current = resolve;
reauthenticationReject.current = reject;
});
reauthenticationPromise.current = pending;
return pending;
});
return () => {
client.setReauthenticationHandler(undefined);
reauthenticationReject.current?.(new Error('Reauthentication was cancelled.'));
reauthenticationPromise.current = undefined;
};
}, [client]);
useEffect(() => {
if (!session?.authenticated) return undefined;
return client.subscribe((event) => {
setLastEventAt(new Date());
setEvents((current) => [event, ...current].slice(0, 40));
setRevisions((current) => ({ ...current, [event.type]: (current[event.type] ?? 0) + 1 }));
if (event.type === 'server-status') setStatus(event.data as ServerStatus);
if (event.type === 'operation') {
const operation = event.data as OperationStatus;
setOperations((current) => [operation, ...current.filter((item) => item.id !== operation.id)].slice(0, 12));
}
}, (streamState) => {
if (streamState === 'open') {
setConnection('online');
setLastError(undefined);
} else {
setConnection((current) => (current === 'offline' ? current : 'degraded'));
}
});
}, [client, session?.authenticated]);
const setup = useCallback(async (bootstrapToken: string, username: string, password: string) => {
const nextSession = await client.setup(bootstrapToken, username, password);
setSession(nextSession);
saveCsrf(nextSession);
await refresh();
}, [client, refresh]);
const login = useCallback(async (username: string, password: string, secondFactor?: string, recovery = false) => {
const nextSession = await client.login(username, password, secondFactor, recovery);
setSession(nextSession);
saveCsrf(nextSession);
await refresh();
}, [client, refresh]);
const logout = useCallback(async () => {
await client.logout();
const nextSession: SessionInfo = { authenticated: false };
setSession(nextSession);
saveCsrf(nextSession);
}, [client]);
const value = useMemo<ServerContextValue>(() => ({
api: client,
connection,
status,
session,
features,
lastError,
lastEventAt,
events,
operations,
revisions,
refresh,
setup,
login,
logout,
}), [client, connection, events, features, lastError, lastEventAt, login, logout, operations, refresh, revisions, session, setup, status]);
const cancelReauthentication = () => {
reauthenticationReject.current?.(new Error('Reauthentication was cancelled.'));
reauthenticationResolve.current = undefined;
reauthenticationReject.current = undefined;
reauthenticationPromise.current = undefined;
setReauthenticationOpen(false);
};
const submitReauthentication = async (event: FormEvent) => {
event.preventDefault();
if (reauthenticationCode.length !== 6) return;
setReauthenticationBusy(true);
setReauthenticationError(undefined);
try {
await client.reauthenticateWithTotp(reauthenticationCode);
const nextSession = await client.session();
setSession(nextSession);
saveCsrf(nextSession);
reauthenticationResolve.current?.();
reauthenticationResolve.current = undefined;
reauthenticationReject.current = undefined;
reauthenticationPromise.current = undefined;
setReauthenticationOpen(false);
setReauthenticationCode('');
} catch (error) {
setReauthenticationError(error instanceof Error ? error.message : String(error));
} finally {
setReauthenticationBusy(false);
}
};
return <ServerContext.Provider value={value}>
{children}
{reauthenticationOpen && <ModalPortal><div className="modal-backdrop" role="presentation">
<section className="modal" role="dialog" aria-modal="true" aria-labelledby="reauthentication-title">
<h2 id="reauthentication-title">双因素认证 / Two-factor authentication</h2>
<p>此操作需要新的动态验证码。验证成功后将自动继续刚才的操作。</p>
<form className="form-stack" onSubmit={submitReauthentication}>
<label className="field">
<span className="field__label">六位验证码 / 6-digit code</span>
<input className="input" autoFocus inputMode="numeric" autoComplete="one-time-code"
pattern="[0-9]{6}" value={reauthenticationCode}
onChange={(event) => setReauthenticationCode(event.target.value.replace(/\D/g, '').slice(0, 6))} />
</label>
{reauthenticationError && <div className="form-error" role="alert">{reauthenticationError}</div>}
<div className="modal__actions">
<button type="button" className="button button--secondary" onClick={cancelReauthentication}
disabled={reauthenticationBusy}>取消 / Cancel</button>
<button type="submit" className="button button--primary"
disabled={reauthenticationBusy || reauthenticationCode.length !== 6}>
{reauthenticationBusy ? '验证中…' : '验证并继续 / Verify & continue'}
</button>
</div>
</form>
</section>
</div></ModalPortal>}
</ServerContext.Provider>;
}
export function useServer(): ServerContextValue {
const context = useContext(ServerContext);
if (!context) throw new Error('useServer must be used inside ServerProvider');
return context;
}
interface ResourceState<T> {
data?: T;
error?: string;
loading: boolean;
reload: () => void;
}
export function useApiResource<T>(
loader: () => Promise<T>,
dependencies: readonly unknown[] = [],
eventTypes: readonly string[] = [],
enabled = true,
): ResourceState<T> {
const { connection, revisions } = useServer();
const [data, setData] = useState<T>();
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const loaderRef = useRef(loader);
loaderRef.current = loader;
const eventRevision = eventTypes.reduce((total, type) => total + (revisions[type] ?? 0), 0);
useEffect(() => {
if (!enabled || connection === 'offline' || connection === 'connecting') {
setLoading(false);
if (!enabled) {
setData(undefined);
setError(undefined);
}
return;
}
let active = true;
setLoading(true);
loaderRef.current()
.then((result) => {
if (!active) return;
setData(result);
setError(undefined);
})
.catch((cause: unknown) => {
if (!active) return;
setError(cause instanceof Error ? cause.message : String(cause));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
// Callers supply stable primitive dependencies. revision refreshes resources after SSE activity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection, enabled, reloadToken, eventRevision, ...dependencies]);
return { data, error, loading, reload: () => setReloadToken((current) => current + 1) };
}
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
type ReactNode,
} from 'react';
import { ApiClient, ApiError, api as defaultApi } from '../lib/api';
import { ModalPortal } from '../components/modal-portal';
import type {
ConnectionState,
FeatureFlags,
OperationStatus,
ServerStatus,
SessionInfo,
StreamEvent,
} from '../types';
interface ServerContextValue {
api: ApiClient;
connection: ConnectionState;
status?: ServerStatus;
session?: SessionInfo;
features?: FeatureFlags;
lastError?: string;
lastEventAt?: Date;
events: StreamEvent[];
operations: OperationStatus[];
revisions: Readonly<Record<string, number>>;
refresh: () => Promise<void>;
setup: (bootstrapToken: string, username: string, password: string) => Promise<void>;
login: (username: string, password: string, secondFactor?: string, recovery?: boolean) => Promise<void>;
logout: () => Promise<void>;
}
const ServerContext = createContext<ServerContextValue | null>(null);
function saveCsrf(session?: SessionInfo): void {
if (session?.csrfToken) sessionStorage.setItem('xfesm.csrf', session.csrfToken);
else sessionStorage.removeItem('xfesm.csrf');
}
export function ServerProvider({ children, client = defaultApi }: { children: ReactNode; client?: ApiClient }) {
const [connection, setConnection] = useState<ConnectionState>('connecting');
const [status, setStatus] = useState<ServerStatus>();
const [session, setSession] = useState<SessionInfo>();
const [features, setFeatures] = useState<FeatureFlags>();
const [lastError, setLastError] = useState<string>();
const [lastEventAt, setLastEventAt] = useState<Date>();
const [events, setEvents] = useState<StreamEvent[]>([]);
const [operations, setOperations] = useState<OperationStatus[]>([]);
const [revisions, setRevisions] = useState<Record<string, number>>({});
const mounted = useRef(true);
const [reauthenticationOpen, setReauthenticationOpen] = useState(false);
const [reauthenticationCode, setReauthenticationCode] = useState('');
const [reauthenticationError, setReauthenticationError] = useState<string>();
const [reauthenticationBusy, setReauthenticationBusy] = useState(false);
const reauthenticationPromise = useRef<Promise<void> | undefined>(undefined);
const reauthenticationResolve = useRef<(() => void) | undefined>(undefined);
const reauthenticationReject = useRef<((error: Error) => void) | undefined>(undefined);
const refresh = useCallback(async () => {
setConnection((current) => (current === 'offline' ? 'connecting' : current));
try {
let nextSession: SessionInfo;
try {
nextSession = await client.session();
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 401) throw error;
const meta = await client.meta();
nextSession = { authenticated: false, bootstrapRequired: meta.setupRequired };
}
if (!mounted.current) return;
setSession(nextSession);
saveCsrf(nextSession);
if (!nextSession.authenticated) {
setStatus(undefined);
setFeatures(undefined);
setConnection('online');
setLastError(undefined);
return;
}
const [nextStatus, nextFeatures] = await Promise.all([client.status(), client.features()]);
if (!mounted.current) return;
setStatus(nextStatus);
setFeatures(nextFeatures);
setConnection('online');
setLastError(undefined);
} catch (error) {
if (!mounted.current) return;
const apiError = error instanceof ApiError ? error : undefined;
setConnection(apiError?.status === 401 || apiError?.status === 403 ? 'degraded' : 'offline');
setLastError(error instanceof Error ? error.message : String(error));
if (apiError?.status === 401) {
const unauthenticated: SessionInfo = { authenticated: false, bootstrapRequired: false };
setSession(unauthenticated);
saveCsrf(unauthenticated);
}
}
}, [client]);
useEffect(() => {
mounted.current = true;
void refresh();
const timer = globalThis.setInterval(() => void refresh(), 10_000);
return () => {
mounted.current = false;
globalThis.clearInterval(timer);
};
}, [refresh]);
useEffect(() => {
client.setReauthenticationHandler(() => {
if (reauthenticationPromise.current) return reauthenticationPromise.current;
setReauthenticationCode('');
setReauthenticationError(undefined);
setReauthenticationOpen(true);
const pending = new Promise<void>((resolve, reject) => {
reauthenticationResolve.current = resolve;
reauthenticationReject.current = reject;
});
reauthenticationPromise.current = pending;
return pending;
});
return () => {
client.setReauthenticationHandler(undefined);
reauthenticationReject.current?.(new Error('Reauthentication was cancelled.'));
reauthenticationPromise.current = undefined;
};
}, [client]);
useEffect(() => {
if (!session?.authenticated) return undefined;
return client.subscribe((event) => {
setLastEventAt(new Date());
setEvents((current) => [event, ...current].slice(0, 40));
setRevisions((current) => ({ ...current, [event.type]: (current[event.type] ?? 0) + 1 }));
if (event.type === 'server-status') setStatus(event.data as ServerStatus);
if (event.type === 'operation') {
const operation = event.data as OperationStatus;
setOperations((current) => [operation, ...current.filter((item) => item.id !== operation.id)].slice(0, 12));
}
}, (streamState) => {
if (streamState === 'open') {
setConnection('online');
setLastError(undefined);
} else {
setConnection((current) => (current === 'offline' ? current : 'degraded'));
}
});
}, [client, session?.authenticated]);
const setup = useCallback(async (bootstrapToken: string, username: string, password: string) => {
const nextSession = await client.setup(bootstrapToken, username, password);
setSession(nextSession);
saveCsrf(nextSession);
await refresh();
}, [client, refresh]);
const login = useCallback(async (username: string, password: string, secondFactor?: string, recovery = false) => {
const nextSession = await client.login(username, password, secondFactor, recovery);
setSession(nextSession);
saveCsrf(nextSession);
await refresh();
}, [client, refresh]);
const logout = useCallback(async () => {
await client.logout();
const nextSession: SessionInfo = { authenticated: false };
setSession(nextSession);
saveCsrf(nextSession);
}, [client]);
const value = useMemo<ServerContextValue>(() => ({
api: client,
connection,
status,
session,
features,
lastError,
lastEventAt,
events,
operations,
revisions,
refresh,
setup,
login,
logout,
}), [client, connection, events, features, lastError, lastEventAt, login, logout, operations, refresh, revisions, session, setup, status]);
const cancelReauthentication = () => {
reauthenticationReject.current?.(new Error('Reauthentication was cancelled.'));
reauthenticationResolve.current = undefined;
reauthenticationReject.current = undefined;
reauthenticationPromise.current = undefined;
setReauthenticationOpen(false);
};
const submitReauthentication = async (event: FormEvent) => {
event.preventDefault();
if (reauthenticationCode.length !== 6) return;
setReauthenticationBusy(true);
setReauthenticationError(undefined);
try {
await client.reauthenticateWithTotp(reauthenticationCode);
const nextSession = await client.session();
setSession(nextSession);
saveCsrf(nextSession);
reauthenticationResolve.current?.();
reauthenticationResolve.current = undefined;
reauthenticationReject.current = undefined;
reauthenticationPromise.current = undefined;
setReauthenticationOpen(false);
setReauthenticationCode('');
} catch (error) {
setReauthenticationError(error instanceof Error ? error.message : String(error));
} finally {
setReauthenticationBusy(false);
}
};
return <ServerContext.Provider value={value}>
{children}
{reauthenticationOpen && <ModalPortal><div className="modal-backdrop" role="presentation">
<section className="modal" role="dialog" aria-modal="true" aria-labelledby="reauthentication-title">
<h2 id="reauthentication-title">双因素认证 / Two-factor authentication</h2>
<p>此操作需要新的动态验证码。验证成功后将自动继续刚才的操作。</p>
<form className="form-stack" onSubmit={submitReauthentication}>
<label className="field">
<span className="field__label">六位验证码 / 6-digit code</span>
<input className="input" autoFocus inputMode="numeric" autoComplete="one-time-code"
pattern="[0-9]{6}" value={reauthenticationCode}
onChange={(event) => setReauthenticationCode(event.target.value.replace(/\D/g, '').slice(0, 6))} />
</label>
{reauthenticationError && <div className="form-error" role="alert">{reauthenticationError}</div>}
<div className="modal__actions">
<button type="button" className="button button--secondary" onClick={cancelReauthentication}
disabled={reauthenticationBusy}>取消 / Cancel</button>
<button type="submit" className="button button--primary"
disabled={reauthenticationBusy || reauthenticationCode.length !== 6}>
{reauthenticationBusy ? '验证中…' : '验证并继续 / Verify & continue'}
</button>
</div>
</form>
</section>
</div></ModalPortal>}
</ServerContext.Provider>;
}
export function useServer(): ServerContextValue {
const context = useContext(ServerContext);
if (!context) throw new Error('useServer must be used inside ServerProvider');
return context;
}
interface ResourceState<T> {
data?: T;
error?: string;
loading: boolean;
reload: () => void;
}
export function useApiResource<T>(
loader: () => Promise<T>,
dependencies: readonly unknown[] = [],
eventTypes: readonly string[] = [],
enabled = true,
): ResourceState<T> {
const { connection, revisions } = useServer();
const [data, setData] = useState<T>();
const [error, setError] = useState<string>();
const [loading, setLoading] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const loaderRef = useRef(loader);
loaderRef.current = loader;
const eventRevision = eventTypes.reduce((total, type) => total + (revisions[type] ?? 0), 0);
useEffect(() => {
if (!enabled || connection === 'offline' || connection === 'connecting') {
setLoading(false);
if (!enabled) {
setData(undefined);
setError(undefined);
}
return;
}
let active = true;
setLoading(true);
loaderRef.current()
.then((result) => {
if (!active) return;
setData(result);
setError(undefined);
})
.catch((cause: unknown) => {
if (!active) return;
setError(cause instanceof Error ? cause.message : String(cause));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
// Callers supply stable primitive dependencies. revision refreshes resources after SSE activity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection, enabled, reloadToken, eventRevision, ...dependencies]);
return { data, error, loading, reload: () => setReloadToken((current) => current + 1) };
}