You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
120 lines
3.5 KiB
120 lines
3.5 KiB
import { request, unwrapApiBody, type ApiResponse } from "../utils/http/factory";
|
|
import type { MinimalUser } from "~~/server/service/auth";
|
|
|
|
type MeResult = {
|
|
user: MinimalUser;
|
|
};
|
|
|
|
type SessionResult = {
|
|
user: MinimalUser | null;
|
|
};
|
|
|
|
export type AuthSessionState = {
|
|
initialized: boolean;
|
|
pending: boolean;
|
|
loggedIn: boolean;
|
|
user: MinimalUser | null;
|
|
};
|
|
|
|
export const AUTH_SESSION_STATE_KEY = "auth:session";
|
|
/** 客户端是否已做过与 Cookie 的一次强制对齐(`ensureClientMeSynced`);登出等场景需重置 */
|
|
export const AUTH_CLIENT_ME_SYNCED_KEY = "auth:client-me-synced";
|
|
export const DEFAULT_AUTH_SESSION_STATE: AuthSessionState = {
|
|
initialized: false,
|
|
pending: false,
|
|
loggedIn: false,
|
|
user: null,
|
|
};
|
|
|
|
function isUnauthorized(error: unknown) {
|
|
if (typeof error !== "object" || error === null) {
|
|
return false;
|
|
}
|
|
return "statusCode" in error && (error as { statusCode?: number }).statusCode === 401;
|
|
}
|
|
|
|
export function useAuthSession() {
|
|
const { $toast } = useNuxtApp()
|
|
const state = useState<AuthSessionState>(AUTH_SESSION_STATE_KEY, () => ({
|
|
...DEFAULT_AUTH_SESSION_STATE,
|
|
}));
|
|
const clientMeSynced = useState<boolean>(AUTH_CLIENT_ME_SYNCED_KEY, () => false);
|
|
|
|
const applyUser = (user: MinimalUser | null) => {
|
|
state.value.user = user;
|
|
state.value.loggedIn = Boolean(user);
|
|
state.value.initialized = true;
|
|
};
|
|
|
|
const clear = async () => {
|
|
await request("/api/auth/logout", { method: "post" })
|
|
clientMeSynced.value = false;
|
|
applyUser(null);
|
|
$toast.success("登出成功")
|
|
};
|
|
|
|
const refresh = async (force = false) => {
|
|
if (state.value.initialized && !force) {
|
|
return state.value.user;
|
|
}
|
|
if (state.value.pending) {
|
|
return state.value.user;
|
|
}
|
|
state.value.pending = true;
|
|
try {
|
|
const fetcher = request;
|
|
const payload = await fetcher<ApiResponse<MeResult>>("/api/auth/me");
|
|
const data = unwrapApiBody(payload);
|
|
applyUser(data.user);
|
|
return data.user;
|
|
} catch (error: unknown) {
|
|
if (isUnauthorized(error)) {
|
|
clear();
|
|
return null;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
state.value.pending = false;
|
|
}
|
|
};
|
|
|
|
const updateProfile = async (data: { username?: string; email?: string; nickname?: string }) => {
|
|
const payload = await request<ApiResponse<{ user: MinimalUser }>>("/api/auth/profile", {
|
|
method: "put",
|
|
body: data,
|
|
});
|
|
const result = unwrapApiBody(payload);
|
|
applyUser(result.user);
|
|
$toast.success("个人资料已更新");
|
|
return result.user;
|
|
};
|
|
|
|
/**
|
|
* 全站 SPA 生命周期内至多同步一次客户端会话(`GET /api/auth/session`)。
|
|
* 无会话 Cookie 时服务端不查库;有 Cookie 时与 `/api/auth/me` 相比少一次额外查询。
|
|
*/
|
|
const ensureClientMeSynced = async (): Promise<void> => {
|
|
if (import.meta.server || clientMeSynced.value) {
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await request<ApiResponse<SessionResult>>("/api/auth/session");
|
|
const data = unwrapApiBody(payload);
|
|
applyUser(data.user);
|
|
clientMeSynced.value = true;
|
|
} catch {
|
|
/* 网络错误等:保留 SSR 状态,下次进入带壳页面可再试 */
|
|
}
|
|
};
|
|
|
|
return {
|
|
initialized: computed(() => state.value.initialized),
|
|
loggedIn: computed(() => state.value.loggedIn),
|
|
user: computed(() => state.value.user),
|
|
pending: computed(() => state.value.pending),
|
|
refresh,
|
|
clear,
|
|
ensureClientMeSynced,
|
|
updateProfile,
|
|
};
|
|
}
|
|
|