feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -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>;