d6be46d313
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
177 lines
8.2 KiB
TypeScript
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);
|
|
}
|