런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -30,6 +30,7 @@ function defaultApiBase(): string {
const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim();
const API_BASE: string = configuredApiBase || defaultApiBase();
export const AUTH_EXPIRED_EVENT = "vignette:auth-expired";
export class ApiError extends Error {
readonly status: number;
@ -96,6 +97,15 @@ async function parseError(res: Response): Promise<ApiError> {
return new ApiError(res.status, detail, body);
}
function notifyAuthExpired(path: string, error: ApiError): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(AUTH_EXPIRED_EVENT, {
detail: { path, status: error.status, detail: error.detail },
}),
);
}
/**
* JSON API . 2xx ApiError throw.
* 204/ undefined .
@ -126,7 +136,9 @@ export async function apiFetch<T = unknown>(
const res = await fetch(joinUrl(path), init);
if (!res.ok) {
throw await parseError(res);
const error = await parseError(res);
if (error.status === 401) notifyAuthExpired(path, error);
throw error;
}
if (res.status === 204) return undefined as T;

View file

@ -7,7 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { api, apiUrl, authApi, type MeResponse } from "./api";
import { AUTH_EXPIRED_EVENT, api, apiUrl, authApi, type MeResponse } from "./api";
export type Role = "learner" | "teacher" | "admin";
export type AccountStatus = "pending" | "approved" | "suspended";
@ -113,6 +113,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const handleAuthExpired = () => {
setUser(null);
setLoading(false);
};
window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
return () => {
window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
};
}, []);
useEffect(() => {
let alive = true;
(async () => {