157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
import { createAsyncThunk, createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
|
import { ApiError } from "../services/api";
|
|
import { changePassword as changePasswordRequest, getAuthStatus, getSession, login as loginRequest, logout as logoutRequest } from "../services/auth";
|
|
import type { Admin, AuthStatus, ChangePasswordInput, LoginCredentials } from "../types/auth";
|
|
|
|
export type SessionStatus = "idle" | "loading" | "authenticated" | "unauthenticated" | "error";
|
|
|
|
export interface SessionState {
|
|
status: SessionStatus;
|
|
admin: Admin | null;
|
|
initialized: boolean | null;
|
|
timezone: string;
|
|
error: string | null;
|
|
bootstrapRequestId: string | null;
|
|
}
|
|
|
|
const initialState: SessionState = {
|
|
status: "idle",
|
|
admin: null,
|
|
initialized: null,
|
|
timezone: "Asia/Shanghai",
|
|
error: null,
|
|
bootstrapRequestId: null,
|
|
};
|
|
|
|
/** Only the initial session check should replace the auth surface with a
|
|
* full-page bootstrap indicator. Login and password requests keep their form
|
|
* mounted so their controls can show progress in place. */
|
|
export function isSessionBootstrapping(state: Pick<SessionState, "status" | "initialized" | "bootstrapRequestId">): boolean {
|
|
return state.status === "idle"
|
|
|| (state.status === "loading" && (state.initialized === null || state.bootstrapRequestId !== null));
|
|
}
|
|
|
|
export const bootstrapSession = createAsyncThunk("session/bootstrap", async () => {
|
|
const authStatus = await getAuthStatus();
|
|
try {
|
|
const session = await getSession();
|
|
return { ...authStatus, admin: session.admin };
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 401) return { ...authStatus, admin: null };
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
export const loginThunk = createAsyncThunk("session/login", async (credentials: LoginCredentials) => {
|
|
const result = await loginRequest(credentials);
|
|
return result.admin;
|
|
});
|
|
|
|
export const changePasswordThunk = createAsyncThunk("session/changePassword", async (input: ChangePasswordInput) => {
|
|
const result = await changePasswordRequest(input);
|
|
return result.admin;
|
|
});
|
|
|
|
export const logoutThunk = createAsyncThunk("session/logout", async () => {
|
|
await logoutRequest();
|
|
});
|
|
|
|
// Friendly aliases keep page code terse while the *Thunk suffix remains explicit at call sites.
|
|
export const restoreSession = bootstrapSession;
|
|
export const login = loginThunk;
|
|
export const changePassword = changePasswordThunk;
|
|
export const logout = logoutThunk;
|
|
|
|
const sessionSlice = createSlice({
|
|
name: "session",
|
|
initialState,
|
|
reducers: {
|
|
clearSessionError(state) {
|
|
state.error = null;
|
|
},
|
|
sessionExpired(state, action: PayloadAction<string | undefined>) {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "unauthenticated";
|
|
state.admin = null;
|
|
state.error = action.payload ?? null;
|
|
},
|
|
setTimezone(state, action: PayloadAction<string>) {
|
|
state.timezone = action.payload;
|
|
},
|
|
},
|
|
extraReducers: (builder) => {
|
|
builder
|
|
.addCase(bootstrapSession.pending, (state, action) => {
|
|
state.status = "loading";
|
|
state.error = null;
|
|
state.bootstrapRequestId = action.meta.requestId;
|
|
})
|
|
.addCase(bootstrapSession.fulfilled, (state, action) => {
|
|
if (state.bootstrapRequestId !== action.meta.requestId) return;
|
|
state.bootstrapRequestId = null;
|
|
state.status = action.payload.admin ? "authenticated" : "unauthenticated";
|
|
state.admin = action.payload.admin;
|
|
state.initialized = action.payload.initialized;
|
|
state.timezone = action.payload.timezone || state.timezone;
|
|
})
|
|
.addCase(bootstrapSession.rejected, (state, action) => {
|
|
if (state.bootstrapRequestId !== action.meta.requestId) return;
|
|
state.bootstrapRequestId = null;
|
|
state.status = "error";
|
|
state.error = action.error.message ?? "无法读取登录状态";
|
|
})
|
|
.addCase(loginThunk.pending, (state) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "loading";
|
|
state.error = null;
|
|
})
|
|
.addCase(loginThunk.fulfilled, (state, action: PayloadAction<Admin>) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "authenticated";
|
|
state.admin = action.payload;
|
|
state.initialized = true;
|
|
state.error = null;
|
|
})
|
|
.addCase(loginThunk.rejected, (state, action) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "unauthenticated";
|
|
state.error = action.error.message ?? "登录失败";
|
|
})
|
|
.addCase(changePasswordThunk.pending, (state) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "loading";
|
|
state.error = null;
|
|
})
|
|
.addCase(changePasswordThunk.fulfilled, (state, action: PayloadAction<Admin>) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "authenticated";
|
|
state.admin = action.payload;
|
|
state.error = null;
|
|
})
|
|
.addCase(changePasswordThunk.rejected, (state, action) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "authenticated";
|
|
state.error = action.error.message ?? "密码更新失败";
|
|
})
|
|
.addCase(logoutThunk.pending, (state) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "loading";
|
|
})
|
|
.addCase(logoutThunk.fulfilled, (state) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "unauthenticated";
|
|
state.admin = null;
|
|
state.error = null;
|
|
})
|
|
.addCase(logoutThunk.rejected, (state, action) => {
|
|
state.bootstrapRequestId = null;
|
|
state.status = "unauthenticated";
|
|
state.admin = null;
|
|
state.error = action.error.message ?? "退出登录失败";
|
|
});
|
|
},
|
|
});
|
|
|
|
export const { clearSessionError, sessionExpired, setTimezone } = sessionSlice.actions;
|
|
export default sessionSlice.reducer;
|