first commit

This commit is contained in:
npc0-hue
2026-07-11 14:56:10 +08:00
commit 7e05d0a4e7
660 changed files with 78119 additions and 0 deletions
+23
View File
@@ -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 };
}
+93
View File
@@ -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 };
}
+69
View File
@@ -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" }
});
}
+281
View File
@@ -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.
}
}