mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Harden auth with revocable sessions, cookies, and single-login default.
Issue short-lived access JWTs backed by AuthSession rows, HttpOnly cookies with refresh rotation, idle timeout, session management UI, WebCRT ownership caps, and optional Redis login rate limits; new logins revoke other sessions by default. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ba33ab5c4f
commit
20c2fcd496
34 changed files with 1462 additions and 149 deletions
|
|
@ -54,6 +54,9 @@ const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default:
|
|||
const ApiTokensPage = lazy(() =>
|
||||
import("./pages/ApiTokensPage").then((m) => ({ default: m.ApiTokensPage })),
|
||||
);
|
||||
const SessionsPage = lazy(() =>
|
||||
import("./pages/SessionsPage").then((m) => ({ default: m.SessionsPage })),
|
||||
);
|
||||
|
||||
/** Preserve query when redirecting legacy /network/webcrt → /webcrt. */
|
||||
function NetworkWebcrtRedirect() {
|
||||
|
|
@ -129,6 +132,7 @@ function ProtectedApp() {
|
|||
<Route path="logs" element={<AuditPage />} />
|
||||
</Route>
|
||||
<Route path="/api-keys" element={<ApiTokensPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -7,14 +7,7 @@ import {
|
|||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
AUTH_TOKEN_KEY,
|
||||
apiGet,
|
||||
apiPost,
|
||||
clearAuthToken,
|
||||
getAuthToken,
|
||||
setAuthToken,
|
||||
} from "../services/api";
|
||||
import { apiGet, apiPost, clearAuthToken } from "../services/api";
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
|
|
@ -44,25 +37,19 @@ const AuthContext = createContext<AuthState | null>(null);
|
|||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||
// token is opaque for UI; cookie session means we only care about user presence.
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [scopes, setScopes] = useState<string[]>([]);
|
||||
|
||||
const refreshMe = useCallback(async () => {
|
||||
const tok = getAuthToken();
|
||||
if (!tok) {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
return;
|
||||
}
|
||||
clearAuthToken(); // drop any legacy localStorage tokens
|
||||
try {
|
||||
const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me");
|
||||
setToken(tok);
|
||||
setToken("cookie");
|
||||
setUser(data.user);
|
||||
setScopes(data.scopes || data.user.scopes || []);
|
||||
} catch {
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
|
|
@ -76,40 +63,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
})();
|
||||
}, [refreshMe]);
|
||||
|
||||
// Other tabs keep React auth state until they hear localStorage change.
|
||||
// `storage` fires only in *other* documents — used to sync logout/login.
|
||||
useEffect(() => {
|
||||
const onStorage = (ev: StorageEvent) => {
|
||||
if (ev.storageArea && ev.storageArea !== localStorage) return;
|
||||
if (ev.key !== null && ev.key !== AUTH_TOKEN_KEY) return;
|
||||
if (ev.key === null || ev.newValue == null || ev.newValue === "") {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
return;
|
||||
}
|
||||
void refreshMe();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [refreshMe]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const data = await apiPost<{ access_token: string; user: AuthUser }>("/v1/auth/login", {
|
||||
const data = await apiPost<{ user: AuthUser }>("/v1/auth/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
setAuthToken(data.access_token);
|
||||
setToken(data.access_token);
|
||||
clearAuthToken();
|
||||
setToken("cookie");
|
||||
setUser(data.user);
|
||||
setScopes(data.user.scopes || []);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
if (getAuthToken()) {
|
||||
await apiPost("/v1/auth/logout", {});
|
||||
}
|
||||
await apiPost("/v1/auth/logout", {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,16 @@ export const MODULES: readonly ModuleDefinition[] = [
|
|||
iconKind: "key",
|
||||
titleKey: "layout.titleApiKeys",
|
||||
},
|
||||
{
|
||||
moduleId: "sessions",
|
||||
path: "/sessions",
|
||||
section: "system",
|
||||
labelKey: "workbench.cards.sessions",
|
||||
descKey: "workbench.cards.sessionsDesc",
|
||||
iconTone: "slate",
|
||||
iconKind: "key",
|
||||
titleKey: "layout.titleSessions",
|
||||
},
|
||||
] as const satisfies readonly ModuleDefinition[];
|
||||
|
||||
export function getModuleById(moduleId: string): ModuleDefinition | undefined {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const en = {
|
|||
logs: "Audit logs",
|
||||
users: "User admin",
|
||||
apiKeys: "API keys",
|
||||
sessions: "Sessions",
|
||||
},
|
||||
announce: {
|
||||
a1: "Dark workbench shell is live — other modules follow the same palette.",
|
||||
|
|
@ -97,6 +98,8 @@ const en = {
|
|||
auditDesc: "Live task overview and operation logs",
|
||||
apiKeys: "API Keys",
|
||||
apiKeysDesc: "Issue MCP/script tokens per user with expiry",
|
||||
sessions: "Login sessions",
|
||||
sessionsDesc: "Review and revoke logins on other devices",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
|
|
@ -519,6 +522,7 @@ const en = {
|
|||
titleUsers: "Users",
|
||||
titleAudit: "Audit",
|
||||
titleApiKeys: "API Keys",
|
||||
titleSessions: "Login sessions",
|
||||
navUme: "UME",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -538,6 +542,18 @@ const en = {
|
|||
loggingIn: "Signing in…",
|
||||
loginFailed: "Login failed",
|
||||
logout: "Sign out",
|
||||
sessionsTitle: "Login sessions",
|
||||
sessionsHint: "Manage browser/device logins for this account. Revoked sessions must sign in again.",
|
||||
sessionsEmpty: "No active sessions.",
|
||||
revokeOtherSessions: "Revoke other sessions",
|
||||
revokeSession: "Revoke",
|
||||
sessionCurrent: "Current",
|
||||
sessionRevoked: "Session revoked",
|
||||
sessionsRevokedOthers: "Revoked {{count}} other session(s)",
|
||||
revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?",
|
||||
colSession: "Session",
|
||||
colLastSeen: "Last seen",
|
||||
colCreated: "Created",
|
||||
usersTitle: "User management",
|
||||
usersHint: "Only admins can create and manage local accounts.",
|
||||
addUser: "Add user",
|
||||
|
|
@ -614,7 +630,7 @@ const en = {
|
|||
confirmPassword: "Confirm new password",
|
||||
savePassword: "Save new password",
|
||||
savingPassword: "Saving…",
|
||||
passwordTooShort: "New password must be at least 6 characters",
|
||||
passwordTooShort: "New password must be at least 8 characters",
|
||||
passwordMismatch: "New passwords do not match",
|
||||
passwordMustChange: "New password must differ from the default/old password",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const zh = {
|
|||
logs: "操作日志",
|
||||
users: "用户管理",
|
||||
apiKeys: "API Key",
|
||||
sessions: "登录会话",
|
||||
},
|
||||
announce: {
|
||||
a1: "深色工作台已上线,其它模块将沿用同一套深色体系。",
|
||||
|
|
@ -97,6 +98,8 @@ const zh = {
|
|||
auditDesc: "任务概览与操作日志",
|
||||
apiKeys: "API Key",
|
||||
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
|
||||
sessions: "登录会话",
|
||||
sessionsDesc: "查看并踢掉其他设备上的登录",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
|
|
@ -515,6 +518,7 @@ const zh = {
|
|||
titleUsers: "用户管理",
|
||||
titleAudit: "操作审计",
|
||||
titleApiKeys: "API Key",
|
||||
titleSessions: "登录会话",
|
||||
navUme: "UME 对接",
|
||||
netxApi: "netx api",
|
||||
oclawBridge: "oclaw WSS",
|
||||
|
|
@ -534,6 +538,18 @@ const zh = {
|
|||
loggingIn: "登录中…",
|
||||
loginFailed: "登录失败",
|
||||
logout: "退出",
|
||||
sessionsTitle: "登录会话",
|
||||
sessionsHint: "管理当前账号在各浏览器/设备上的登录。踢掉会话后对方需重新登录。",
|
||||
sessionsEmpty: "当前没有活跃会话。",
|
||||
revokeOtherSessions: "踢掉其他会话",
|
||||
revokeSession: "踢掉",
|
||||
sessionCurrent: "当前",
|
||||
sessionRevoked: "会话已吊销",
|
||||
sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话",
|
||||
revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?",
|
||||
colSession: "会话",
|
||||
colLastSeen: "最近活动",
|
||||
colCreated: "创建时间",
|
||||
usersTitle: "用户管理",
|
||||
usersHint: "仅管理员可创建与管理本地账号。",
|
||||
addUser: "添加用户",
|
||||
|
|
@ -609,7 +625,7 @@ const zh = {
|
|||
confirmPassword: "确认新密码",
|
||||
savePassword: "保存新密码",
|
||||
savingPassword: "保存中…",
|
||||
passwordTooShort: "新密码至少 6 位",
|
||||
passwordTooShort: "新密码至少 8 位",
|
||||
passwordMismatch: "两次输入的新密码不一致",
|
||||
passwordMustChange: "新密码不能与默认/旧密码相同",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function ForceChangePasswordPage() {
|
|||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (newPassword.length < 6) {
|
||||
if (newPassword.length < 8) {
|
||||
setError(t("auth.passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ export function ForceChangePasswordPage() {
|
|||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
|
|
@ -90,7 +90,7 @@ export function ForceChangePasswordPage() {
|
|||
onChange={(e) => setConfirm(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
|
|
|
|||
118
web/src/pages/SessionsPage.tsx
Normal file
118
web/src/pages/SessionsPage.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { apiDelete, apiGet, apiPost } from "../services/api";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
created_at: string | null;
|
||||
expires_at: string | null;
|
||||
refresh_expires_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
client_ip: string;
|
||||
user_agent: string;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
export function SessionsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ["auth-sessions"],
|
||||
queryFn: () => apiGet<{ items: SessionRow[]; total: number }>("/v1/auth/sessions"),
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/v1/auth/sessions/${encodeURIComponent(id)}`),
|
||||
onSuccess: () => {
|
||||
showOk(t("auth.sessionRevoked"));
|
||||
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
|
||||
},
|
||||
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
|
||||
});
|
||||
|
||||
const revokeOthersMut = useMutation({
|
||||
mutationFn: () => apiPost<{ revoked: number }>("/v1/auth/sessions/revoke-others", {}),
|
||||
onSuccess: (data) => {
|
||||
showOk(t("auth.sessionsRevokedOthers", { count: data.revoked ?? 0 }));
|
||||
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
|
||||
},
|
||||
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
|
||||
});
|
||||
|
||||
const items = sessionsQuery.data?.items || [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="page-header">
|
||||
<h1>{t("auth.sessionsTitle")}</h1>
|
||||
<p className="panel__hint">{t("auth.sessionsHint")}</p>
|
||||
</header>
|
||||
|
||||
<div className="panel">
|
||||
<div className="filter-inline" style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
|
||||
onClick={() => revokeOthersMut.mutate()}
|
||||
>
|
||||
{t("auth.revokeOtherSessions")}
|
||||
</button>
|
||||
<button type="button" onClick={() => void sessionsQuery.refetch()} disabled={sessionsQuery.isFetching}>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
||||
{!items.length && !sessionsQuery.isLoading ? (
|
||||
<p className="muted">{t("auth.sessionsEmpty")}</p>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("auth.colSession")}</th>
|
||||
<th>{t("auth.colIp")}</th>
|
||||
<th>{t("auth.colLastSeen")}</th>
|
||||
<th>{t("auth.colCreated")}</th>
|
||||
<th>{t("auth.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<code title={row.user_agent}>{row.id.slice(0, 10)}…</code>
|
||||
{row.current ? (
|
||||
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
|
||||
{t("auth.sessionCurrent")}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{row.client_ip || "—"}</td>
|
||||
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
|
||||
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
disabled={revokeMut.isPending}
|
||||
onClick={() => {
|
||||
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
|
||||
revokeMut.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("auth.revokeSession")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ export function UsersPage() {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="user">{t("auth.roleUser")}</option>
|
||||
|
|
@ -155,7 +155,7 @@ export function UsersPage() {
|
|||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 6}
|
||||
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
|
||||
onClick={() => {
|
||||
const pwd = resetPwd[u.id];
|
||||
patchMut.mutate({ id: u.id, body: { password: pwd } });
|
||||
|
|
|
|||
|
|
@ -50,36 +50,40 @@ import type {
|
|||
|
||||
|
||||
export const AUTH_TOKEN_KEY = "netx_access_token";
|
||||
export const AUTH_REFRESH_KEY = "netx_refresh_token";
|
||||
|
||||
export const getAuthToken = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setAuthToken = (token: string): void => {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, String(token || ""));
|
||||
};
|
||||
|
||||
/** Clear legacy localStorage tokens; browser auth now uses HttpOnly cookies. */
|
||||
export const clearAuthToken = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(AUTH_REFRESH_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
/** @deprecated Cookie session — always null for browser UI. */
|
||||
export const getAuthToken = (): string | null => null;
|
||||
|
||||
/** No-op kept for call-site compatibility during cookie migration. */
|
||||
export const setAuthToken = (_token: string): void => {
|
||||
clearAuthToken();
|
||||
};
|
||||
|
||||
export const setAuthTokens = (_access: string, _refresh?: string | null): void => {
|
||||
clearAuthToken();
|
||||
};
|
||||
|
||||
const fetchCreds: RequestCredentials = "include";
|
||||
|
||||
const authHeaders = (extra?: Record<string, string>): Record<string, string> => {
|
||||
const h: Record<string, string> = { accept: "application/json", ...(extra || {}) };
|
||||
const tok = getAuthToken();
|
||||
if (tok) h.authorization = `Bearer ${tok}`;
|
||||
return h;
|
||||
// Browser JWT rides HttpOnly cookies (credentials: include).
|
||||
// Authorization Bearer is only needed for non-browser API tokens if ever injected.
|
||||
return { accept: "application/json", ...(extra || {}) };
|
||||
};
|
||||
|
||||
const handleUnauthorized = (path: string): void => {
|
||||
if (path.startsWith("/v1/auth/login")) return;
|
||||
if (path.startsWith("/v1/auth/login") || path.startsWith("/v1/auth/refresh")) return;
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
|
||||
const next = `${window.location.pathname}${window.location.search || ""}`;
|
||||
|
|
@ -87,6 +91,38 @@ const handleUnauthorized = (path: string): void => {
|
|||
}
|
||||
};
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
const tryRefreshAccessToken = async (): Promise<boolean> => {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = (async () => {
|
||||
try {
|
||||
// Refresh token comes from HttpOnly cookie when body is empty.
|
||||
const res = await fetch("/v1/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
clearAuthToken();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
clearAuthToken();
|
||||
return false;
|
||||
} finally {
|
||||
refreshInFlight = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
const shouldAttemptRefresh = (path: string): boolean =>
|
||||
!path.startsWith("/v1/auth/login") && !path.startsWith("/v1/auth/refresh");
|
||||
|
||||
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
|
|
@ -139,7 +175,10 @@ export function formatApiDetail(detail: unknown): string {
|
|||
}
|
||||
|
||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
let res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
}
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("401 unauthorized");
|
||||
|
|
@ -149,11 +188,17 @@ export const apiGet = async <T,>(path: string): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -164,11 +209,17 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "PATCH",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -179,7 +230,10 @@ export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||
const res = await fetch(path, { method: "DELETE", headers: authHeaders() });
|
||||
let res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await fetch(path, { method: "DELETE", headers: authHeaders(), credentials: fetchCreds });
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -190,11 +244,17 @@ export const apiDelete = async <T,>(path: string): Promise<T> => {
|
|||
};
|
||||
|
||||
export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const doFetch = () =>
|
||||
fetch(path, {
|
||||
method: "PUT",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "content-type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res = await doFetch();
|
||||
if (res.status === 401 && shouldAttemptRefresh(path) && (await tryRefreshAccessToken())) {
|
||||
res = await doFetch();
|
||||
}
|
||||
const data = await parseApiResponse(res);
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -499,7 +559,7 @@ export const managedNeImportTemplateUrl = (format: "xlsx" | "csv" = "xlsx") =>
|
|||
|
||||
export const downloadManagedNeImportTemplate = async (format: "xlsx" | "csv" = "xlsx"): Promise<void> => {
|
||||
const path = managedNeImportTemplateUrl(format);
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
@ -527,7 +587,7 @@ export const importManagedNe = async (file: File): Promise<ManagedNeImportResult
|
|||
form.append("file", file);
|
||||
const path = "/v1/managed-ne/import";
|
||||
// Do not set content-type — browser must add multipart boundary.
|
||||
const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form });
|
||||
const res = await fetch(path, { method: "POST", headers: authHeaders(), body: form, credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
@ -673,7 +733,7 @@ export function closeWebcrtSessionsKeepalive(sessionIds: string[]): void {
|
|||
for (const id of ids) {
|
||||
const path = `/v1/webcrt/sessions/${encodeURIComponent(id)}`;
|
||||
try {
|
||||
void fetch(path, { method: "DELETE", headers, keepalive: true });
|
||||
void fetch(path, { method: "DELETE", headers, credentials: fetchCreds, keepalive: true });
|
||||
} catch {
|
||||
/* ignore unload failures */
|
||||
}
|
||||
|
|
@ -772,6 +832,7 @@ async function webcrtSftpDownloadOnce(
|
|||
const path = "/v1/webcrt/sftp/download";
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: fetchCreds,
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
signal: opts?.signal,
|
||||
|
|
@ -863,8 +924,7 @@ function webcrtSftpUploadOnce(
|
|||
opts.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
xhr.open("POST", path);
|
||||
const tok = getAuthToken();
|
||||
if (tok) xhr.setRequestHeader("Authorization", `Bearer ${tok}`);
|
||||
xhr.withCredentials = true;
|
||||
xhr.responseType = "text";
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (!opts?.onProgress) return;
|
||||
|
|
@ -1414,7 +1474,7 @@ export const downloadNeConfigSnapshot = async (
|
|||
const path =
|
||||
`/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}` +
|
||||
`/download?field=${encodeURIComponent(field)}`;
|
||||
const res = await fetch(path, { headers: authHeaders() });
|
||||
const res = await fetch(path, { headers: authHeaders(), credentials: fetchCreds });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
throw new Error("unauthorized");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue