feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -4,14 +4,23 @@ import {
|
|||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { AUTH_EXPIRED_EVENT, api, apiUrl, authApi, type MeResponse } from "./api";
|
||||
import {
|
||||
AUTH_EXPIRED_EVENT,
|
||||
ApiError,
|
||||
api,
|
||||
apiUrl,
|
||||
authApi,
|
||||
type MeResponse,
|
||||
} from "./api";
|
||||
|
||||
export type Role = "learner" | "teacher" | "admin";
|
||||
export type AccountStatus = "pending" | "approved" | "suspended";
|
||||
export type DesignRole = "learner" | "instructor" | "admin";
|
||||
export type AuthRestoreState = "loading" | "retrying" | "ready" | "failed";
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
|
|
@ -34,6 +43,8 @@ export interface AuthContextValue {
|
|||
user: AuthUser | null;
|
||||
role: Role | null;
|
||||
loading: boolean;
|
||||
restoreState: AuthRestoreState;
|
||||
retryRestore: () => Promise<void>;
|
||||
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<AuthUser | null>;
|
||||
|
|
@ -102,6 +113,47 @@ const DEV_NAME_BY_ROLE: Record<Role, string> = {
|
|||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
const AUTH_RESTORE_TIMEOUT_MS = 5_000;
|
||||
const AUTH_RESTORE_RETRY_DELAYS_MS = [0, 400, 1_000] as const;
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isSignedOut(error: unknown) {
|
||||
return error instanceof ApiError && error.status === 401;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재부팅 직후 API가 아직 올라오는 중이어도 세션을 곧바로 버리지 않는다.
|
||||
* 각 요청은 유한 시간 안에 끝내고, 짧은 재시도 뒤에도 연결되지 않을 때만
|
||||
* 명시적인 복구 화면으로 넘긴다.
|
||||
*/
|
||||
async function restoreCurrentUser(onRetry: () => void): Promise<MeResponse> {
|
||||
let lastError: unknown = new Error("auth restore failed");
|
||||
|
||||
for (let attempt = 0; attempt < AUTH_RESTORE_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||
const delayMs = AUTH_RESTORE_RETRY_DELAYS_MS[attempt];
|
||||
if (delayMs > 0) {
|
||||
onRetry();
|
||||
await wait(delayMs);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), AUTH_RESTORE_TIMEOUT_MS);
|
||||
try {
|
||||
return await api.get<MeResponse>("/auth/me", { signal: controller.signal });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isSignedOut(error)) throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function userFromMe(me: MeResponse): AuthUser {
|
||||
const onboarding = me as MeResponse & { onboarding_completed_at?: number | null };
|
||||
const nickname = (me.nickname ?? "").trim();
|
||||
|
|
@ -127,11 +179,14 @@ function userFromMe(me: MeResponse): AuthUser {
|
|||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [restoreState, setRestoreState] = useState<AuthRestoreState>("loading");
|
||||
const initialRestoreStarted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAuthExpired = () => {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
setRestoreState("ready");
|
||||
};
|
||||
window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
|
||||
return () => {
|
||||
|
|
@ -139,23 +194,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api.get<MeResponse>("/auth/me");
|
||||
if (alive) setUser(userFromMe(me));
|
||||
} catch {
|
||||
if (alive) setUser(null);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
const retryRestore = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setRestoreState("loading");
|
||||
try {
|
||||
const me = await restoreCurrentUser(() => setRestoreState("retrying"));
|
||||
setUser(userFromMe(me));
|
||||
setRestoreState("ready");
|
||||
} catch (error) {
|
||||
if (isSignedOut(error)) {
|
||||
setUser(null);
|
||||
setRestoreState("ready");
|
||||
} else {
|
||||
setRestoreState("failed");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRestoreStarted.current) return;
|
||||
initialRestoreStarted.current = true;
|
||||
void retryRestore();
|
||||
}, [retryRestore]);
|
||||
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
if (user) body.setAttribute("data-role", designRoleOf(user.role));
|
||||
|
|
@ -210,13 +273,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
user,
|
||||
role: user?.role ?? null,
|
||||
loading,
|
||||
restoreState,
|
||||
retryRestore,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
acceptConsent,
|
||||
withdrawConsent,
|
||||
}),
|
||||
[user, loading, login, logout, refresh, acceptConsent, withdrawConsent],
|
||||
[
|
||||
user,
|
||||
loading,
|
||||
restoreState,
|
||||
retryRestore,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
acceptConsent,
|
||||
withdrawConsent,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue