Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/stores/auth.ts
T
2026-07-18 10:10:05 +02:00

177 lines
8.2 KiB
TypeScript

import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { authAPI, HWLAB_WEB_SESSION_COOKIE } from "@/api";
import type { AccessUserRole, AccessUserStatus, AuthAccess, AuthCapabilities, AuthCapabilityStatus, AuthSession, AuthState, AuthUser } from "@/types";
import { asRecord, nonEmptyString } from "@/utils";
export const useAuthStore = defineStore("auth", () => {
const authState = ref<AuthState>("checking");
const session = ref<AuthSession | null>(null);
const error = ref<string | null>(null);
const checked = ref(false);
const isAuthenticated = computed(() => authState.value === "authenticated" && session.value?.authenticated === true);
const user = computed(() => session.value?.user ?? session.value?.actor ?? null);
const isAdmin = computed(() => session.value?.capabilities?.admin === "available" || user.value?.role === "admin");
const allowedNavIds = computed(() => session.value?.access?.nav?.allowedIds ?? []);
const firstAllowedPath = computed(() => firstAllowedNavPath(allowedNavIds.value));
function canAccessNav(navId: unknown): boolean {
const id = nonEmptyString(navId);
if (!id) return true;
const allowed = allowedNavIds.value;
return allowed.includes("*") || allowed.includes(id);
}
async function bootstrap(): Promise<void> {
if (authState.value !== "checking") checked.value = true;
const current = await authAPI.session();
if (current.ok && current.data?.authenticated === true) {
session.value = sessionFromPayload(current.data);
authState.value = "authenticated";
checked.value = true;
document.body.dataset.authState = "authenticated";
return;
}
const explicitUnauthenticated = current.status === 401 || current.status === 403 || (current.ok && current.data?.authenticated === false);
if (!explicitUnauthenticated) {
error.value = current.error || "认证状态暂时不可达,保持当前页面并稍后重试。";
checked.value = authState.value === "authenticated";
document.body.dataset.authState = authState.value === "authenticated" ? "authenticated" : "checking";
return;
}
session.value = null;
authState.value = "login";
checked.value = true;
document.body.dataset.authState = "login";
}
async function login(username: string, password: string): Promise<void> {
error.value = null;
const response = await authAPI.login(username.trim(), password);
if (!response.ok || response.data?.authenticated !== true) {
error.value = loginFailureMessage(response);
authState.value = "login";
return;
}
session.value = sessionFromPayload(response.data);
authState.value = "authenticated";
checked.value = true;
document.body.dataset.authState = "authenticated";
}
async function register(payload: { email: string; username: string; displayName?: string; password: string }): Promise<void> {
error.value = null;
const response = await authAPI.register({
email: payload.email.trim(),
username: payload.username.trim(),
displayName: payload.displayName?.trim() ?? "",
password: payload.password
});
if (!response.ok || response.data?.authenticated !== true) {
error.value = response.error || "注册失败,请检查邮箱、用户名和密码。";
authState.value = "login";
return;
}
session.value = sessionFromPayload(response.data);
authState.value = "authenticated";
checked.value = true;
document.body.dataset.authState = "authenticated";
}
async function logout(): Promise<void> {
await authAPI.logout();
session.value = null;
authState.value = "login";
document.body.dataset.authState = "login";
}
return { authState, checked, session, error, isAuthenticated, user, isAdmin, allowedNavIds, firstAllowedPath, canAccessNav, bootstrap, login, register, logout, cookieName: HWLAB_WEB_SESSION_COOKIE };
});
function sessionFromPayload(payload: { authenticated?: boolean; authMethod?: unknown; identityAuthority?: unknown; sessionKind?: unknown; user?: unknown; actor?: unknown; capabilities?: unknown; access?: unknown; expiresAt?: string | null; sessionExpiresAt?: string | null; valuesRedacted?: unknown }): AuthSession {
return {
authenticated: payload.authenticated === true,
mode: "server",
authMethod: nonEmptyString(payload.authMethod) ?? null,
identityAuthority: nonEmptyString(payload.identityAuthority) ?? null,
sessionKind: nonEmptyString(payload.sessionKind) ?? null,
user: userFromUnknown(payload.user),
actor: userFromUnknown(payload.actor),
capabilities: capabilitiesFromUnknown(payload.capabilities),
access: accessFromUnknown(payload.access),
expiresAt: nonEmptyString(payload.expiresAt) ?? nonEmptyString(payload.sessionExpiresAt),
valuesRedacted: payload.valuesRedacted === true
};
}
function userFromUnknown(value: unknown): AuthUser | undefined {
const record = asRecord(value);
if (!record) return undefined;
return { id: nonEmptyString(record.id) ?? undefined, username: nonEmptyString(record.username) ?? undefined, name: nonEmptyString(record.name) ?? undefined, displayName: nonEmptyString(record.displayName) ?? undefined, role: accessRoleFromUnknown(record.role), status: accessStatusFromUnknown(record.status) };
}
function accessRoleFromUnknown(value: unknown): AccessUserRole | undefined {
return value === "admin" || value === "user" ? value : undefined;
}
function accessStatusFromUnknown(value: unknown): AccessUserStatus | undefined {
return value === "active" || value === "disabled" ? value : undefined;
}
function capabilitiesFromUnknown(value: unknown): AuthCapabilities | undefined {
const record = asRecord(value);
if (!record) return undefined;
const output: AuthCapabilities = {};
for (const [key, item] of Object.entries(record)) {
const status = nonEmptyString(item) as AuthCapabilityStatus | undefined;
if (status) output[key] = status;
}
return Object.keys(output).length > 0 ? output : undefined;
}
function accessFromUnknown(value: unknown): AuthAccess | undefined {
const record = asRecord(value);
const nav = asRecord(record?.nav);
if (!nav) return undefined;
const allowedIds = Array.isArray(nav.allowedIds) ? nav.allowedIds.map(nonEmptyString).filter((item): item is string => Boolean(item)) : [];
return { nav: { profileId: nonEmptyString(nav.profileId) ?? undefined, allowedIds, valuesRedacted: nav.valuesRedacted === true } };
}
function firstAllowedNavPath(allowedIds: string[]): string {
const ordered = [
{ id: "workbench.code", path: "/workbench" },
{ id: "workbench.debug", path: "/workbench/debug" },
{ id: "opencode.root", path: "/opencode" },
{ id: "project.mdtodo", path: "/projects/mdtodo" },
{ id: "project.tasktree", path: "/projects/tasktree" },
{ id: "user.dashboard", path: "/dashboard" },
{ id: "project.overview", path: "/projects" },
{ id: "user.billing", path: "/billing" },
{ id: "system.settings", path: "/settings" }
];
if (allowedIds.includes("*")) return "/workbench";
return ordered.find((item) => allowedIds.includes(item.id))?.path ?? "/workbench";
}
function loginFailureMessage(response: Awaited<ReturnType<typeof authAPI.login>>): string {
const code = loginErrorCode(response.data) ?? nonEmptyString(response.error);
const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? "";
if (response.status === 401 || response.status === 403 || ["invalid-credentials", "unauthorized", "forbidden"].includes(normalizedCode)) {
return "账号或密码不正确,请重新输入。";
}
if (response.status === 0) {
return "登录服务暂时不可达,请稍后重试。";
}
if (response.status >= 500 || /(?:upstream|unavailable|timeout|proxy|bad-gateway)/u.test(normalizedCode)) {
return code ? `登录服务暂时不可用,请稍后重试。错误码:${code}` : "登录服务暂时不可用,请稍后重试。";
}
return response.error ? `登录失败:${response.error}` : "登录失败,请稍后重试。";
}
function loginErrorCode(payload: unknown): string | null {
const record = asRecord(payload);
const error = asRecord(record?.error);
return nonEmptyString(error?.code) ?? nonEmptyString(record?.code);
}