first commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import type { PageId, PageParams } from "../contracts/page";
|
||||
import type { CurrentUserView } from "../contracts/workspace";
|
||||
import { canAccessRoute, defaultPageForUser, resolveRouteHash } from "../routes/routes";
|
||||
import { firstPartyRoutes } from "../routes/routes";
|
||||
|
||||
const routeIds: PageId[] = firstPartyRoutes.map((route) => route.id);
|
||||
|
||||
export function isPageId(value: string): value is PageId {
|
||||
return routeIds.includes(value as PageId);
|
||||
}
|
||||
|
||||
export interface NavigationState {
|
||||
pageId: PageId;
|
||||
params: PageParams;
|
||||
}
|
||||
|
||||
export function initialNavigationState(user: CurrentUserView, hash = typeof window === "undefined" ? "" : window.location.hash): NavigationState {
|
||||
const resolved = resolveRouteHash(hash, user);
|
||||
if (!canAccessRoute(user, resolved.route.id)) {
|
||||
return { pageId: defaultPageForUser(user), params: {} };
|
||||
}
|
||||
return { pageId: resolved.route.id, params: resolved.params };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import type { JobResponse } from "../api/types";
|
||||
import type { OperationRecord, OperationStatus } from "../contracts/workspace";
|
||||
|
||||
export interface BeginOperationInput {
|
||||
intent: string;
|
||||
targetKind: OperationRecord["targetKind"];
|
||||
targetId: string;
|
||||
requester?: string;
|
||||
}
|
||||
|
||||
export interface OperationUpdate {
|
||||
status?: OperationStatus;
|
||||
job?: JobResponse;
|
||||
message?: string;
|
||||
errorReason?: string;
|
||||
diagnosticId?: string;
|
||||
}
|
||||
|
||||
export interface OperationTracker {
|
||||
operations: OperationRecord[];
|
||||
begin: (input: BeginOperationInput) => string;
|
||||
update: (operationId: string, update: OperationUpdate) => void;
|
||||
succeed: (operationId: string, message?: string, job?: JobResponse) => void;
|
||||
fail: (operationId: string, errorReason: string, diagnosticId?: string) => void;
|
||||
isPending: (targetId: string, intent?: string) => boolean;
|
||||
}
|
||||
|
||||
export function useOperationTracker(): OperationTracker {
|
||||
const [operations, setOperations] = useState<OperationRecord[]>([]);
|
||||
const sequence = useRef(0);
|
||||
|
||||
const begin = useCallback((input: BeginOperationInput): string => {
|
||||
sequence.current += 1;
|
||||
const now = new Date().toISOString();
|
||||
const record: OperationRecord = {
|
||||
id: `op-${Date.now()}-${sequence.current}`,
|
||||
intent: input.intent,
|
||||
targetKind: input.targetKind,
|
||||
targetId: input.targetId,
|
||||
requester: input.requester ?? "当前用户",
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
setOperations((current) => [record, ...current]);
|
||||
return record.id;
|
||||
}, []);
|
||||
|
||||
const update = useCallback((operationId: string, patch: OperationUpdate) => {
|
||||
setOperations((current) =>
|
||||
current.map((operation) =>
|
||||
operation.id === operationId
|
||||
? {
|
||||
...operation,
|
||||
status: patch.status ?? operation.status,
|
||||
jobId: patch.job?.id ?? operation.jobId,
|
||||
jobState: patch.job?.state ?? operation.jobState,
|
||||
message: patch.message ?? operation.message,
|
||||
errorReason: patch.errorReason ?? operation.errorReason,
|
||||
diagnosticId: patch.diagnosticId ?? operation.diagnosticId,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
: operation
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const succeed = useCallback(
|
||||
(operationId: string, message?: string, job?: JobResponse) => {
|
||||
update(operationId, { status: "succeeded", message, job });
|
||||
},
|
||||
[update]
|
||||
);
|
||||
|
||||
const fail = useCallback(
|
||||
(operationId: string, errorReason: string, diagnosticId?: string) => {
|
||||
update(operationId, { status: "failed", errorReason, diagnosticId: diagnosticId ?? operationId });
|
||||
},
|
||||
[update]
|
||||
);
|
||||
|
||||
const isPending = useCallback(
|
||||
(targetId: string, intent?: string) =>
|
||||
operations.some(
|
||||
(operation) => operation.status === "pending" && operation.targetId === targetId && (intent === undefined || operation.intent === intent)
|
||||
),
|
||||
[operations]
|
||||
);
|
||||
|
||||
return { operations, begin, update, succeed, fail, isPending };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { setPlatformApiSessionToken } from "../api/client";
|
||||
import { rolesFromBackendRoles } from "../contracts/workspace";
|
||||
import { currentUserFromResponse, loadCurrentUser, localFallbackUser } from "./session";
|
||||
|
||||
describe("session store helpers", () => {
|
||||
afterEach(() => {
|
||||
setPlatformApiSessionToken(null);
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not grant platform administrator access for local fallback", () => {
|
||||
expect(localFallbackUser.roles).toEqual(["serverAdmin"]);
|
||||
expect(localFallbackUser.capabilities).not.toContain("users.manage");
|
||||
});
|
||||
|
||||
it("maps backend platform admin roles only from API responses", () => {
|
||||
const user = currentUserFromResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] }, "api");
|
||||
expect(user.roles).toEqual(["platformAdmin"]);
|
||||
expect(user.capabilities).toContain("users.manage");
|
||||
expect(rolesFromBackendRoles(["server-admin"])).toEqual(["serverAdmin"]);
|
||||
});
|
||||
|
||||
it("returns null without a stored API session token", async () => {
|
||||
stubWindowStorage(new Map());
|
||||
await expect(loadCurrentUser()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("loads current user with a stored API bearer token", async () => {
|
||||
const storage = new Map([["platform-web.session.apiToken", "session-token"]]);
|
||||
stubWindowStorage(storage);
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token");
|
||||
return jsonResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(loadCurrentUser()).resolves.toMatchObject({ id: "user-admin", roles: ["platformAdmin"], source: "api" });
|
||||
expect(storage.get("platform-web.session.apiToken")).toBe("session-token");
|
||||
});
|
||||
|
||||
it("clears stale stored API session tokens", async () => {
|
||||
const storage = new Map([["platform-web.session.apiToken", "stale-token"]]);
|
||||
stubWindowStorage(storage);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ message: "authentication required" }, 401)));
|
||||
|
||||
await expect(loadCurrentUser()).resolves.toBeNull();
|
||||
expect(storage.has("platform-web.session.apiToken")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function stubWindowStorage(values: Map<string, string>) {
|
||||
vi.stubGlobal("window", {
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { platformApiClient, setPlatformApiSessionToken } from "../api/client";
|
||||
import type {
|
||||
AuthSessionResponse,
|
||||
CurrentUserResponse,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
UserProfileUpdateRequest,
|
||||
UserThemePreferenceRequest,
|
||||
UserThemePreferenceResponse
|
||||
} from "../api/types";
|
||||
import type { CurrentUserView } from "../contracts/workspace";
|
||||
import { capabilitiesForRoles, rolesFromBackendRoles } from "../contracts/workspace";
|
||||
import { readWebRuntimeEnv } from "../schemas/env";
|
||||
import { defaultThemeBackgroundId, defaultThemePaletteId, loadThemeState } from "../theme/tokens";
|
||||
|
||||
const sessionTokenStorageKey = "platform-web.session.apiToken";
|
||||
const localFallbackEnabled = readWebRuntimeEnv().enableLocalAuthFallback;
|
||||
|
||||
export const localFallbackUser: CurrentUserView = {
|
||||
id: "local-server-operator",
|
||||
displayName: "Local Server Operator",
|
||||
email: "local.server@example.test",
|
||||
status: "active",
|
||||
roles: ["serverAdmin"],
|
||||
capabilities: capabilitiesForRoles(["serverAdmin"]),
|
||||
profile: {
|
||||
phone: "",
|
||||
qq: "",
|
||||
contactNote: "本地开发回退账号,仅限服务器工作台"
|
||||
},
|
||||
source: "local"
|
||||
};
|
||||
|
||||
export interface AuthFormState {
|
||||
mode: "login" | "register";
|
||||
pending: boolean;
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
user?: CurrentUserView;
|
||||
loaded: boolean;
|
||||
authenticated: boolean;
|
||||
authUnavailable: boolean;
|
||||
localFallbackAvailable: boolean;
|
||||
auth: AuthFormState;
|
||||
login: (request: LoginRequest) => Promise<void>;
|
||||
register: (request: RegisterRequest) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
continueWithLocalFallback: () => void;
|
||||
switchAuthMode: (mode: AuthFormState["mode"]) => void;
|
||||
updateProfile: (request: UserProfileUpdateRequest) => Promise<CurrentUserView>;
|
||||
updateThemePreference: (request: UserThemePreferenceRequest) => Promise<UserThemePreferenceResponse>;
|
||||
}
|
||||
|
||||
export async function loadCurrentUser(): Promise<CurrentUserView | null> {
|
||||
const storedToken = readStoredSessionToken();
|
||||
setPlatformApiSessionToken(storedToken);
|
||||
if (!storedToken) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await platformApiClient.getCurrentUser();
|
||||
return currentUserFromResponse(response, "api");
|
||||
} catch {
|
||||
persistSessionToken(null);
|
||||
setPlatformApiSessionToken(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSession(): SessionState {
|
||||
const [user, setUser] = useState<CurrentUserView | undefined>();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [authUnavailable, setAuthUnavailable] = useState(false);
|
||||
const [auth, setAuth] = useState<AuthFormState>({ mode: "login", pending: false });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadCurrentUser().then((currentUser) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setUser(currentUser ?? undefined);
|
||||
setAuthUnavailable(!currentUser);
|
||||
setLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function login(request: LoginRequest) {
|
||||
setAuth({ mode: "login", pending: true });
|
||||
try {
|
||||
const response = await platformApiClient.login(request);
|
||||
finishAuth(response, "登录成功");
|
||||
} catch (error) {
|
||||
setAuth({
|
||||
mode: "login",
|
||||
pending: false,
|
||||
error: error instanceof Error ? error.message : "登录失败,请重试。"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function register(request: RegisterRequest) {
|
||||
setAuth({ mode: "register", pending: true });
|
||||
try {
|
||||
const response = await platformApiClient.register(request);
|
||||
const nextUser = currentUserFromResponse(response.user, "api");
|
||||
if (response.status === "authenticated") {
|
||||
setUser(nextUser);
|
||||
persistSessionToken(response.sessionId ?? null);
|
||||
setPlatformApiSessionToken(response.sessionId ?? null);
|
||||
}
|
||||
setAuth({
|
||||
mode: response.status === "authenticated" ? "login" : "register",
|
||||
pending: false,
|
||||
success: response.message ?? (response.status === "authenticated" ? "注册成功,已进入工作台。" : "注册申请已提交,等待平台管理员审核。")
|
||||
});
|
||||
} catch (error) {
|
||||
setAuth({
|
||||
mode: "register",
|
||||
pending: false,
|
||||
error: error instanceof Error ? error.message : "注册失败,请稍后重试。"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await platformApiClient.logout();
|
||||
} catch {
|
||||
// Local fallback logout still clears the visible session.
|
||||
}
|
||||
persistSessionToken(null);
|
||||
setPlatformApiSessionToken(null);
|
||||
setUser(undefined);
|
||||
setAuthUnavailable(false);
|
||||
setAuth({ mode: "login", pending: false, success: "已退出登录。" });
|
||||
}
|
||||
|
||||
function continueWithLocalFallback() {
|
||||
if (!localFallbackEnabled) {
|
||||
setAuth({ mode: "login", pending: false, error: "本地回退未启用,请使用真实账号登录。" });
|
||||
return;
|
||||
}
|
||||
setUser(localFallbackUser);
|
||||
setAuthUnavailable(false);
|
||||
setAuth({ mode: "login", pending: false, success: "已进入本地回退工作台。" });
|
||||
}
|
||||
|
||||
function switchAuthMode(mode: AuthFormState["mode"]) {
|
||||
setAuth({ mode, pending: false });
|
||||
}
|
||||
|
||||
async function updateProfile(request: UserProfileUpdateRequest): Promise<CurrentUserView> {
|
||||
const current = user ?? localFallbackUser;
|
||||
try {
|
||||
const response = await platformApiClient.updateCurrentUserProfile(request);
|
||||
const nextUser = currentUserFromResponse(response, "api");
|
||||
setUser(nextUser);
|
||||
return nextUser;
|
||||
} catch {
|
||||
const nextUser: CurrentUserView = {
|
||||
...current,
|
||||
displayName: request.displayName,
|
||||
profile: {
|
||||
avatarUrl: request.avatarUrl,
|
||||
phone: request.phone,
|
||||
qq: request.qq,
|
||||
contactNote: request.contactNote
|
||||
},
|
||||
source: "local"
|
||||
};
|
||||
setUser(nextUser);
|
||||
return nextUser;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateThemePreference(request: UserThemePreferenceRequest): Promise<UserThemePreferenceResponse> {
|
||||
const current = user ?? localFallbackUser;
|
||||
try {
|
||||
const response = await platformApiClient.updateCurrentUserTheme(request);
|
||||
setUser({ ...current, themePreference: response, source: "api" });
|
||||
return response;
|
||||
} catch {
|
||||
const response: UserThemePreferenceResponse = {
|
||||
...request,
|
||||
userId: current.id,
|
||||
persistence: "local",
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
const nextUser = { ...current, themePreference: response, source: "local" as const };
|
||||
setUser(nextUser);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
function finishAuth(response: AuthSessionResponse, fallbackMessage: string) {
|
||||
const nextUser = currentUserFromResponse(response.user, "api");
|
||||
setUser(nextUser);
|
||||
persistSessionToken(response.sessionId ?? null);
|
||||
setPlatformApiSessionToken(response.sessionId ?? null);
|
||||
setAuthUnavailable(false);
|
||||
setAuth({ mode: "login", pending: false, success: response.message ?? fallbackMessage });
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loaded,
|
||||
authenticated: Boolean(user),
|
||||
authUnavailable,
|
||||
localFallbackAvailable: localFallbackEnabled,
|
||||
auth,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
continueWithLocalFallback,
|
||||
switchAuthMode,
|
||||
updateProfile,
|
||||
updateThemePreference
|
||||
};
|
||||
}
|
||||
|
||||
export function currentUserFromResponse(response: CurrentUserResponse, source: CurrentUserView["source"]): CurrentUserView {
|
||||
const roles = rolesFromBackendRoles(response.roles);
|
||||
const themeState = loadThemeState();
|
||||
return {
|
||||
id: response.id,
|
||||
displayName: response.displayName,
|
||||
email: response.email,
|
||||
status: response.status ?? "active",
|
||||
roles,
|
||||
capabilities: response.capabilities?.length ? (response.capabilities as CurrentUserView["capabilities"]) : capabilitiesForRoles(roles),
|
||||
profile: response.profile ?? {},
|
||||
themePreference:
|
||||
response.themePreference ??
|
||||
(source === "local"
|
||||
? {
|
||||
userId: response.id,
|
||||
paletteId: themeState.paletteId || defaultThemePaletteId,
|
||||
backgroundPresetId: themeState.backgroundPresetId || defaultThemeBackgroundId,
|
||||
backgroundImage: themeState.backgroundImage,
|
||||
persistence: "local",
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
: undefined),
|
||||
source
|
||||
};
|
||||
}
|
||||
|
||||
function readStoredSessionToken(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(sessionTokenStorageKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSessionToken(token: string | null) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (token) {
|
||||
window.localStorage.setItem(sessionTokenStorageKey, token);
|
||||
} else {
|
||||
window.localStorage.removeItem(sessionTokenStorageKey);
|
||||
}
|
||||
} catch {
|
||||
// Storage can be unavailable; the in-memory session remains usable.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user