feat(ai-character): 관리자 인증 셸 구현

This commit is contained in:
Yu Sung
2026-07-27 15:04:03 +09:00
parent ba43dd829e
commit 5356bd1e6a
82 changed files with 6017 additions and 18 deletions

View File

@@ -1,10 +1,288 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { server } from "@/shared/test/server";
test("renders Korean root shell with a main landmark", () => {
const apiBaseUrl = "https://api.example.com";
function saveAdminSession() {
authSessionStorage.save({ token: "admin-token", role: "ADMIN" });
}
function useAiCharactersResponse(status: 200 | 401 | 403 = 200, onRequest: (request: Request) => void = () => undefined) {
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, ({ request }) => {
onRequest(request);
if (status === 401) {
return HttpResponse.json(
{ success: false, message: "인증 정보가 없습니다.", data: null, errorProperty: null },
{ status },
);
}
if (status === 403) {
return HttpResponse.json(
{ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null },
{ status },
);
}
return HttpResponse.json({ success: true, message: null, data: null, errorProperty: null });
}),
);
}
beforeEach(() => {
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
});
afterEach(() => {
vi.unstubAllEnvs();
window.history.replaceState({}, "", "/");
});
test("preserves the Korean document language", () => {
render(<App />);
expect(document.documentElement).toHaveAttribute("lang", "ko");
expect(screen.getByRole("main")).toHaveTextContent("AI 캐릭터 관리자");
expect(screen.getByRole("main")).toContainElement(screen.getByRole("heading", { name: "관리자 로그인" }));
});
test("redirects an unauthenticated direct visit to /ai-characters without exposing protected content", async () => {
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
});
test("renders the existing login page at /login", () => {
window.history.pushState({}, "", "/login");
render(<App />);
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "로그인" })).toBeInTheDocument();
});
test("navigates to /ai-characters after a successful login", async () => {
window.history.pushState({}, "", "/login");
useAiCharactersResponse();
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({
success: true,
message: null,
data: { token: "jwt-token", role: "ADMIN" },
errorProperty: null,
}),
),
);
render(<App />);
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
expect(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
});
test("renders the protected admin shell for an existing ADMIN session", async () => {
saveAdminSession();
const requests: Request[] = [];
useAiCharactersResponse(200, (request) => requests.push(request));
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
expect(requests[0]?.url).toContain("size=20");
expect(screen.getByRole("link", { name: "본문으로 건너뛰기" })).toHaveAttribute("href", "#app-main");
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "데스크톱 주 메뉴" })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "브레드크럼" })).toHaveTextContent("AI 캐릭터");
expect(screen.getByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).toBeInTheDocument();
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("composes Task 1.5 shared empty state in the real admin shell without domain list data", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
expect(await screen.findByRole("main", { name: "AI 캐릭터 관리" })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Phase 2에서 AI 캐릭터 목록이 연결됩니다.");
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("clears the session and routes to login when the protected route request returns 401", async () => {
saveAdminSession();
useAiCharactersResponse(401);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("heading", { name: "관리자 로그인" })).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
});
test("routes to access denied without clearing the session when the protected route request returns 403", async () => {
saveAdminSession();
useAiCharactersResponse(403);
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(authSessionStorage.read()).toEqual({ token: "admin-token", role: "ADMIN" });
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
expect(screen.queryByText("루나")).not.toBeInTheDocument();
});
test("keeps the protected shell hidden while a stale ADMIN probe is pending and then denied", async () => {
saveAdminSession();
window.history.pushState({}, "", "/ai-characters");
let resolveDenyProbeReady: (denyProbe: () => void) => void = () => undefined;
const denyProbeReady = new Promise<() => void>((resolve) => {
resolveDenyProbeReady = resolve;
});
server.use(
http.get(`${apiBaseUrl}/api/v2/admin/ai-characters`, () =>
new Promise((resolve) => {
resolveDenyProbeReady(() =>
resolve(
HttpResponse.json(
{ success: false, message: "접근 권한이 없습니다.", data: null, errorProperty: null },
{ status: 403 },
),
),
);
}),
),
);
render(<App />);
const triggerDenyProbe = await denyProbeReady;
expect(screen.queryByRole("main", { name: "AI 캐릭터 관리" })).not.toBeInTheDocument();
expect(screen.queryByText("Phase 2에서 AI 캐릭터 목록이 연결됩니다.")).not.toBeInTheDocument();
triggerDenyProbe();
await waitFor(() => expect(window.location.pathname).toBe("/access-denied"));
expect(screen.getByRole("heading", { name: "접근 권한이 없습니다" })).toBeInTheDocument();
});
test("keeps keyboard focus inside the mobile menu and returns focus to the trigger", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
render(<App />);
await screen.findByRole("heading", { name: "AI 캐릭터" });
const menuButton = screen.getByRole("button", { name: "모바일 메뉴 열기" });
fireEvent.click(menuButton);
const menu = screen.getByRole("navigation", { name: "모바일 주 메뉴" });
const closeButton = screen.getByRole("button", { name: "모바일 메뉴 닫기" });
await waitFor(() => expect(closeButton).toHaveFocus());
fireEvent.keyDown(menu, { key: "Tab", shiftKey: true });
expect(screen.getByRole("link", { name: "AI 캐릭터" })).toHaveFocus();
fireEvent.keyDown(menu, { key: "Tab" });
expect(closeButton).toHaveFocus();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("navigation", { name: "모바일 주 메뉴" })).not.toBeInTheDocument();
expect(menuButton).toHaveFocus();
});
test("shows a login warning when server logout confirmation fails", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(
http.post(`${apiBaseUrl}/member/logout`, () =>
HttpResponse.json(
{ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null },
{ status: 500 },
),
),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
});
test("logs out from the protected shell and routes to login", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
server.use(
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
expect(request.headers.get("Authorization")).toBe("Bearer admin-token");
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
}),
);
render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "로그아웃" }));
await waitFor(() => expect(window.location.pathname).toBe("/login"));
expect(authSessionStorage.read()).toBeNull();
});
test("sends only one logout request while the first logout is in flight", async () => {
saveAdminSession();
useAiCharactersResponse();
window.history.pushState({}, "", "/ai-characters");
let logoutCount = 0;
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
const logoutReady = new Promise<() => void>((resolve) => {
resolveLogoutReady = resolve;
});
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => {
logoutCount += 1;
return new Promise((resolve) => {
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
});
}),
);
render(<App />);
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
fireEvent.click(logoutButton);
await waitFor(() => expect(logoutCount).toBe(1));
const finishLogout = await logoutReady;
finishLogout();
await waitForElementToBeRemoved(logoutButton);
expect(window.location.pathname).toBe("/login");
});

View File

@@ -1,13 +1,263 @@
import { useEffect } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
import { LoginPage } from "@/features/auth/pages/LoginPage";
import { AuthSessionProvider } from "@/features/auth/model/auth-session";
import { useAuthSession } from "@/features/auth/model/auth-session-context";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { AccessDeniedPage, AiCharactersPage } from "@/app/admin-pages";
import { routePaths } from "@/app/route-paths";
import { navigateTo, replaceWith, useBrowserLocation } from "@/app/browser-location";
import { AccessDeniedError } from "@/shared/api/api-error";
import { createApiClient } from "@/shared/api/client";
const aiCharactersRouteResponseSchema = z.unknown();
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
function NavLink() {
return (
<a
className="rounded-md px-3 py-2 text-sm font-semibold text-accent-foreground hover:bg-accent"
href={routePaths.aiCharacters}
onClick={(event) => {
event.preventDefault();
navigateTo(routePaths.aiCharacters);
}}
>
AI
</a>
);
}
function ProtectedAdminShell({ routeError }: { readonly routeError: string | null }) {
const auth = useAuthSession();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const mobileMenuRef = useRef<HTMLElement>(null);
const shouldRestoreMenuFocusRef = useRef(false);
useEffect(() => {
if (isMobileMenuOpen || !shouldRestoreMenuFocusRef.current) {
return;
}
shouldRestoreMenuFocusRef.current = false;
menuButtonRef.current?.focus();
}, [isMobileMenuOpen]);
useEffect(() => {
if (!isMobileMenuOpen) {
return undefined;
}
closeButtonRef.current?.focus();
function closeOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
shouldRestoreMenuFocusRef.current = true;
setIsMobileMenuOpen(false);
}
}
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [isMobileMenuOpen]);
function keepFocusInMobileMenu(event: React.KeyboardEvent<HTMLElement>) {
if (event.key !== "Tab") {
return;
}
const focusableElements = Array.from(mobileMenuRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
const firstElement = focusableElements[0];
const lastElement = focusableElements.at(-1);
if (firstElement === undefined || lastElement === undefined) {
return;
}
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
return;
}
if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
function closeMobileMenu() {
shouldRestoreMenuFocusRef.current = true;
setIsMobileMenuOpen(false);
}
return (
<div className="flex min-h-[100dvh] bg-background text-foreground">
<div aria-hidden={isMobileMenuOpen} className="contents" inert={isMobileMenuOpen}>
<a className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-modal focus:rounded-md focus:bg-card focus:px-4 focus:py-2 focus:text-link" href="#app-main">
</a>
<aside className="hidden w-60 shrink-0 border-r border-border bg-card p-4 lg:block">
<p className="mb-4 text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
<nav aria-label="데스크톱 주 메뉴" className="flex flex-col gap-2">
<NavLink />
</nav>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex min-h-14 items-center justify-between gap-3 border-b border-border bg-card px-4" role="banner">
<div className="flex min-w-0 items-center gap-3">
<button
aria-expanded={isMobileMenuOpen}
className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold lg:hidden"
onClick={() => setIsMobileMenuOpen(true)}
ref={menuButtonRef}
type="button"
>
</button>
<nav aria-label="브레드크럼" className="text-sm text-muted-foreground">
<ol className="flex items-center gap-2">
<li></li>
<li aria-hidden="true">/</li>
<li className="font-semibold text-foreground">AI </li>
</ol>
</nav>
</div>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void auth.logout()} type="button">
</button>
</header>
<main aria-label="AI 캐릭터 관리" className="min-h-0 flex-1 overflow-auto p-4" id="app-main">
<AiCharactersPage routeError={routeError} />
</main>
</div>
</div>
{isMobileMenuOpen ? (
<div className="fixed inset-0 z-overlay bg-background/80 lg:hidden">
<nav
aria-label="모바일 주 메뉴"
className="flex min-h-[100dvh] w-[min(15rem,50vw)] flex-col gap-3 border-r border-border bg-card p-4"
onKeyDown={keepFocusInMobileMenu}
ref={mobileMenuRef}
>
<button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold" onClick={closeMobileMenu} ref={closeButtonRef} type="button">
</button>
<NavLink />
</nav>
</div>
) : null}
</div>
);
}
function AppShell() {
const auth = useAuthSession();
const protectedRouteApiClient = useMemo(
() =>
createApiClient({
getToken: () => authSessionStorage.read()?.token ?? null,
clearSession: () => auth.clearSession(sessionExpiredNotice),
onAuthExpired: () => replaceWith(routePaths.login),
}),
[auth],
);
const location = useBrowserLocation();
const [routeError, setRouteError] = useState<string | null>(null);
const [verifiedProtectedRouteToken, setVerifiedProtectedRouteToken] = useState<string | null>(null);
useEffect(() => {
if (location !== routePaths.login && auth.session === null) {
replaceWith(routePaths.login);
}
}, [auth.session, location]);
useEffect(() => {
if (location !== routePaths.aiCharacters || auth.session === null) {
return undefined;
}
let isCurrent = true;
const sessionToken = auth.session.token;
void protectedRouteApiClient
.request({
path: "/api/v2/admin/ai-characters?page=0&size=20",
responseSchema: aiCharactersRouteResponseSchema,
authentication: "required",
})
.then(() => {
if (isCurrent) {
setRouteError(null);
setVerifiedProtectedRouteToken(sessionToken);
}
})
.catch((error: unknown) => {
if (!isCurrent) {
return;
}
if (error instanceof AccessDeniedError) {
replaceWith(routePaths.accessDenied);
return;
}
setRouteError("보호 route 확인에 실패했습니다.");
});
return () => {
isCurrent = false;
};
}, [auth.session, location, protectedRouteApiClient]);
if (location === routePaths.login) {
return (
<LoginPage
notice={auth.loginNotice}
onSubmit={async (credentials) => {
await auth.login(credentials);
navigateTo(routePaths.aiCharacters);
}}
/>
);
}
if (auth.session === null) {
return null;
}
if (location === routePaths.accessDenied) {
return <AccessDeniedPage />;
}
if (location === routePaths.aiCharacters && verifiedProtectedRouteToken !== auth.session.token) {
return null;
}
return <ProtectedAdminShell routeError={routeError} />;
}
export function App() {
const apiClient = useMemo(
() =>
createApiClient({
getToken: () => authSessionStorage.read()?.token ?? null,
clearSession: authSessionStorage.remove,
onAuthExpired: () => replaceWith(routePaths.login),
}),
[],
);
useEffect(() => {
document.documentElement.lang = "ko";
}, []);
return (
<main>
<h1>AI </h1>
</main>
<AuthSessionProvider apiClient={apiClient} onNavigateLogin={replaceWith}>
<AppShell />
</AuthSessionProvider>
);
}

37
src/app/admin-pages.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { routePaths } from "@/app/route-paths";
import { PageState } from "@/shared/ui/page-state";
export function AccessDeniedPage() {
return (
<main className="flex min-h-[100dvh] items-center justify-center bg-background px-4 text-foreground">
<section className="flex max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6">
<p className="text-xs font-semibold text-info">ACCESS DENIED</p>
<h1 className="text-2xl font-bold leading-tight"> </h1>
<p className="text-sm text-muted-foreground">ADMIN .</p>
<a className="font-semibold text-link hover:text-link-hover" href={routePaths.login}>
</a>
</section>
</main>
);
}
export function AiCharactersPage({ routeError }: { readonly routeError: string | null }) {
return (
<section className="flex flex-col gap-4" aria-labelledby="ai-characters-title">
<div className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
<h1 className="text-2xl font-bold leading-tight" id="ai-characters-title">
AI
</h1>
<p className="text-sm text-muted-foreground"> Phase 2 .</p>
</div>
{routeError === null ? null : (
<p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">
{routeError}
</p>
)}
<PageState description="현재 route는 보호 shell과 권한 처리를 검증하는 명시적 빈 상태입니다." state="empty" title="Phase 2에서 AI 캐릭터 목록이 연결됩니다." />
</section>
);
}

View File

@@ -0,0 +1,33 @@
import { useSyncExternalStore } from "react";
import { routePaths, type RoutePath } from "@/app/route-paths";
function subscribe(onStoreChange: () => void): () => void {
window.addEventListener("popstate", onStoreChange);
return () => window.removeEventListener("popstate", onStoreChange);
}
function getSnapshot(): RoutePath {
const path = window.location.pathname;
if (path === routePaths.login || path === routePaths.aiCharacters || path === routePaths.accessDenied) {
return path;
}
return routePaths.aiCharacters;
}
export function useBrowserLocation(): RoutePath {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export function navigateTo(path: RoutePath): void {
window.history.pushState({}, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
}
export function replaceWith(path: RoutePath): void {
window.history.replaceState({}, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
}

7
src/app/route-paths.ts Normal file
View File

@@ -0,0 +1,7 @@
export const routePaths = {
accessDenied: "/access-denied",
login: "/login",
aiCharacters: "/ai-characters",
} as const;
export type RoutePath = (typeof routePaths)[keyof typeof routePaths];

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
import type { ApiClient } from "@/shared/api/client";
const loginResponseSchema = z.object({
token: z.string().min(1),
role: z.literal("ADMIN"),
});
const logoutResponseSchema = z.object({});
export function login(apiClient: ApiClient, credentials: LoginCredentials): Promise<AuthSessionRecord> {
return apiClient.request({
path: "/admin/member/login",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: credentials.email, password: credentials.password }),
responseSchema: loginResponseSchema,
authentication: "none",
});
}
export async function logout(apiClient: ApiClient): Promise<void> {
await apiClient.request({
path: "/member/logout",
method: "POST",
responseSchema: logoutResponseSchema,
authentication: "required",
});
}

View File

@@ -0,0 +1,32 @@
import { createContext, useContext } from "react";
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
class MissingAuthSessionProviderError extends Error {
override readonly name = "MissingAuthSessionProviderError";
constructor() {
super("AuthSessionProvider is required.");
}
}
export type AuthSessionContextValue = {
readonly session: AuthSessionRecord | null;
readonly loginNotice: string | null;
readonly login: (credentials: LoginCredentials) => Promise<void>;
readonly logout: () => Promise<void>;
readonly clearSession: (loginNotice?: string | null) => void;
};
export const AuthSessionContext = createContext<AuthSessionContextValue | null>(null);
export function useAuthSession(): AuthSessionContextValue {
const context = useContext(AuthSessionContext);
if (context === null) {
throw new MissingAuthSessionProviderError();
}
return context;
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import {
authSessionStorage,
type AuthSessionRecord,
} from "./auth-session-storage";
describe("authSessionStorage", () => {
const session: AuthSessionRecord = {
token: "header.payload.signature",
role: "ADMIN",
};
test("reads a saved ADMIN session", () => {
// Given
authSessionStorage.save(session);
// When
const restoredSession = authSessionStorage.read();
// Then
expect(restoredSession).toEqual(session);
});
test("removes a saved session", () => {
// Given
authSessionStorage.save(session);
// When
authSessionStorage.remove();
// Then
expect(authSessionStorage.read()).toBeNull();
});
});

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
export type AuthSessionRecord = {
readonly token: string;
readonly role: "ADMIN";
};
const authSessionStorageKey = "ai-character-admin-auth-session";
const authSessionRecordSchema = z.object({
token: z.string().min(1),
role: z.literal("ADMIN"),
});
function parseAuthSession(value: string): AuthSessionRecord | null {
try {
return authSessionRecordSchema.parse(JSON.parse(value));
} catch (error) {
if (error instanceof SyntaxError || error instanceof z.ZodError) {
return null;
}
throw error;
}
}
export const authSessionStorage = {
read(): AuthSessionRecord | null {
const value = sessionStorage.getItem(authSessionStorageKey);
return value === null ? null : parseAuthSession(value);
},
save(session: AuthSessionRecord): void {
sessionStorage.setItem(authSessionStorageKey, JSON.stringify(session));
},
remove(): void {
sessionStorage.removeItem(authSessionStorageKey);
},
};

View File

@@ -0,0 +1,77 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { login as requestLogin, logout as requestLogout } from "@/features/auth/api/auth-api";
import { AuthSessionContext } from "@/features/auth/model/auth-session-context";
import type { AuthSessionContextValue } from "@/features/auth/model/auth-session-context";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import type { AuthSessionRecord } from "@/features/auth/model/auth-session-storage";
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
import type { ApiClient } from "@/shared/api/client";
const logoutFailureWarning = "서버 로그아웃 확인에 실패했습니다.";
export type AuthSessionProviderProps = {
readonly apiClient: ApiClient;
readonly children: ReactNode;
readonly onNavigateLogin?: (path: "/login") => void;
};
export function AuthSessionProvider({ apiClient, children, onNavigateLogin }: AuthSessionProviderProps) {
const [session, setSession] = useState<AuthSessionRecord | null>(() => authSessionStorage.read());
const [loginNotice, setLoginNotice] = useState<string | null>(null);
const logoutPromiseRef = useRef<Promise<void> | null>(null);
const clearSession = useCallback((nextLoginNotice: string | null = null) => {
authSessionStorage.remove();
setSession(null);
setLoginNotice(nextLoginNotice);
}, []);
const login = useCallback(
async (credentials: LoginCredentials) => {
const nextSession = await requestLogin(apiClient, credentials);
authSessionStorage.save(nextSession);
setSession(nextSession);
setLoginNotice(null);
},
[apiClient],
);
const logout = useCallback(async () => {
if (logoutPromiseRef.current !== null) {
return logoutPromiseRef.current;
}
const logoutPromise = (async () => {
let didLogoutRequestFail = false;
try {
await requestLogout(apiClient);
} catch (error) {
if (error instanceof Error) {
didLogoutRequestFail = true;
} else {
didLogoutRequestFail = true;
}
}
authSessionStorage.remove();
setSession(null);
setLoginNotice(didLogoutRequestFail ? logoutFailureWarning : null);
onNavigateLogin?.("/login");
})().finally(() => {
logoutPromiseRef.current = null;
});
logoutPromiseRef.current = logoutPromise;
return logoutPromise;
}, [apiClient, onNavigateLogin]);
const value = useMemo<AuthSessionContextValue>(
() => ({ session, loginNotice, login, logout, clearSession }),
[clearSession, login, loginNotice, logout, session],
);
return <AuthSessionContext.Provider value={value}>{children}</AuthSessionContext.Provider>;
}

View File

@@ -0,0 +1,156 @@
import { useId, useRef, useState } from "react";
import { z } from "zod";
import { loginSchema } from "@/features/auth/schemas/login-schema";
import type { LoginCredentials } from "@/features/auth/schemas/login-schema";
type LoginFieldErrors = {
readonly email: string | null;
readonly password: string | null;
};
export type LoginPageProps = {
readonly notice?: string | null;
readonly onSubmit: (credentials: LoginCredentials) => Promise<void>;
};
const emptyErrors: LoginFieldErrors = { email: null, password: null };
function getCredentials(form: HTMLFormElement): LoginCredentials {
const formData = new FormData(form);
return {
email: String(formData.get("email") ?? ""),
password: String(formData.get("password") ?? ""),
};
}
function getFieldErrors(error: z.ZodError<LoginCredentials>): LoginFieldErrors {
const fieldErrors = z.flattenError(error).fieldErrors;
return {
email: fieldErrors.email?.[0] ?? null,
password: fieldErrors.password?.[0] ?? null,
};
}
export function LoginPage({ notice = null, onSubmit }: LoginPageProps) {
const emailErrorId = useId();
const passwordErrorId = useId();
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const [errors, setErrors] = useState<LoginFieldErrors>(emptyErrors);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(form: HTMLFormElement): Promise<void> {
const parsedCredentials = loginSchema.safeParse(getCredentials(form));
if (!parsedCredentials.success) {
const nextErrors = getFieldErrors(parsedCredentials.error);
setErrors(nextErrors);
if (nextErrors.email !== null) {
emailRef.current?.focus();
} else if (nextErrors.password !== null) {
passwordRef.current?.focus();
}
return;
}
setErrors(emptyErrors);
setSubmitError(null);
setIsSubmitting(true);
try {
await onSubmit(parsedCredentials.data);
} catch (error) {
if (error instanceof Error) {
setSubmitError(error.message || "로그인에 실패했습니다.");
return;
}
throw error;
} finally {
setIsSubmitting(false);
}
}
return (
<main className="min-h-[100dvh] bg-background px-4 py-12 text-foreground sm:px-6">
<section className="mx-auto flex w-full max-w-sm flex-col gap-6 rounded-lg border border-border bg-card p-6">
<header className="flex flex-col gap-2">
<p className="text-xs font-semibold text-info">AI CHARACTER ADMIN</p>
<h1 className="text-2xl font-bold leading-tight"> </h1>
<p className="text-sm text-muted-foreground">ADMIN .</p>
</header>
{notice === null ? null : (
<p className="rounded-md border border-warning bg-warning-surface p-3 text-sm font-semibold text-warning" role="alert">
{notice}
</p>
)}
<form
className="flex flex-col gap-4"
noValidate
onSubmit={(event) => {
event.preventDefault();
void handleSubmit(event.currentTarget);
}}
>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor="login-email">
</label>
<input
aria-describedby={errors.email === null ? undefined : emailErrorId}
aria-invalid={errors.email !== null}
autoComplete="email"
className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground"
id="login-email"
name="email"
ref={emailRef}
type="email"
/>
{errors.email === null ? null : (
<p className="text-sm font-semibold text-destructive" id={emailErrorId} role="alert">
{errors.email}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor="login-password">
</label>
<input
aria-describedby={errors.password === null ? undefined : passwordErrorId}
aria-invalid={errors.password !== null}
autoComplete="current-password"
className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground"
id="login-password"
name="password"
ref={passwordRef}
type="password"
/>
{errors.password === null ? null : (
<p className="text-sm font-semibold text-destructive" id={passwordErrorId} role="alert">
{errors.password}
</p>
)}
</div>
{submitError === null ? null : (
<p className="text-sm font-semibold text-destructive" role="alert">
{submitError}
</p>
)}
<button
className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60"
disabled={isSubmitting}
type="submit"
>
</button>
</form>
</section>
</main>
);
}

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
email: z.email("올바른 이메일을 입력하세요."),
password: z.string().min(1, "비밀번호를 입력하세요."),
});
export type LoginCredentials = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,107 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { login, logout } from "@/features/auth/api/auth-api";
import { createApiClient } from "@/shared/api/client";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
function createClient(token: string | null = "header.payload.signature") {
return createApiClient({
getToken: () => token,
clearSession: vi.fn(),
onAuthExpired: vi.fn(),
});
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe("auth API", () => {
test("posts email and password JSON only to admin login without Authorization", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const client = createClient("caller-token");
const observedRequests: string[] = [];
let authorization: string | null = null;
let contentType: string | null = null;
let body: unknown = null;
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, async ({ request }) => {
observedRequests.push(new URL(request.url).pathname);
authorization = request.headers.get("Authorization");
contentType = request.headers.get("Content-Type");
body = await request.json();
return HttpResponse.json({
success: true,
message: null,
data: { token: "jwt-token", role: "ADMIN" },
errorProperty: null,
});
}),
http.all(`${apiBaseUrl}/refresh`, ({ request }) => {
observedRequests.push(new URL(request.url).pathname);
return HttpResponse.json({ success: false, message: "unexpected", data: null }, { status: 500 });
}),
);
// When
const session = await login(client, { email: "admin@test.com", password: "password" });
// Then
expect(session).toEqual({ token: "jwt-token", role: "ADMIN" });
expect(authorization).toBeNull();
expect(contentType).toContain("application/json");
expect(body).toEqual({ email: "admin@test.com", password: "password" });
expect(observedRequests).toEqual(["/admin/member/login"]);
});
test.each([
{ name: "empty token", data: { token: "", role: "ADMIN" } },
{ name: "missing token", data: { role: "ADMIN" } },
{ name: "missing role", data: { token: "jwt-token" } },
{ name: "non ADMIN role", data: { token: "jwt-token", role: "USER" } },
])("rejects $name login response", async ({ data }) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const client = createClient();
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({ success: true, message: null, data, errorProperty: null }),
),
);
// When
const request = login(client, { email: "admin@test.com", password: "password" });
// Then
await expect(request).rejects.toThrow("API 응답 형식이 올바르지 않습니다.");
});
test("posts logout once with Bearer and no body", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const client = createClient("logout-token");
let calls = 0;
let authorization: string | null = null;
let body = "not-read";
server.use(
http.post(`${apiBaseUrl}/member/logout`, async ({ request }) => {
calls += 1;
authorization = request.headers.get("Authorization");
body = await request.text();
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
}),
);
// When
await logout(client);
// Then
expect(calls).toBe(1);
expect(authorization).toBe("Bearer logout-token");
expect(body).toBe("");
});
});

View File

@@ -0,0 +1,203 @@
import { render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { useState } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { useAuthSession } from "@/features/auth/model/auth-session-context";
import { AuthSessionProvider } from "@/features/auth/model/auth-session";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { createApiClient } from "@/shared/api/client";
import { server } from "@/shared/test/server";
const apiBaseUrl = "https://api.example.com";
function SessionProbe() {
const auth = useAuthSession();
const [loginFailed, setLoginFailed] = useState(false);
return (
<section>
<div>{auth.session === null ? "보호 콘텐츠 없음" : "보호 콘텐츠"}</div>
<div>{auth.session?.token ?? "토큰 없음"}</div>
{loginFailed ? <p role="status"> </p> : null}
{auth.loginNotice === null ? null : <p role="alert">{auth.loginNotice}</p>}
<button
onClick={() =>
void auth.login({ email: "admin@test.com", password: "password" }).catch(() => {
setLoginFailed(true);
})
}
type="button"
>
</button>
<button onClick={() => void auth.logout()} type="button">
</button>
</section>
);
}
function renderSession(onNavigateLogin = vi.fn()) {
const client = createApiClient({
getToken: () => authSessionStorage.read()?.token ?? null,
clearSession: authSessionStorage.remove,
onAuthExpired: onNavigateLogin,
});
render(
<AuthSessionProvider apiClient={client} onNavigateLogin={onNavigateLogin}>
<SessionProbe />
</AuthSessionProvider>,
);
return { onNavigateLogin };
}
afterEach(() => {
vi.unstubAllEnvs();
document.cookie = "auth=; Max-Age=0; path=/";
});
describe("auth session model", () => {
test("rejects stored session with empty token", () => {
// Given
sessionStorage.setItem(
"ai-character-admin-auth-session",
JSON.stringify({ token: "", role: "ADMIN" }),
);
// When, Then
expect(authSessionStorage.read()).toBeNull();
});
test("stores successful ADMIN login only in sessionStorage and restores it in the same tab", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const indexedDbOpen = vi.fn();
vi.stubGlobal("indexedDB", { open: indexedDbOpen });
const cookieBefore = document.cookie;
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({
success: true,
message: null,
data: { token: "jwt-token", role: "ADMIN" },
errorProperty: null,
}),
),
);
// When
renderSession();
screen.getByRole("button", { name: "로그인 실행" }).click();
// Then
await screen.findByText("jwt-token");
expect(authSessionStorage.read()).toEqual({ token: "jwt-token", role: "ADMIN" });
expect(localStorage).toHaveLength(0);
expect(indexedDbOpen).not.toHaveBeenCalled();
expect(document.cookie).toBe(cookieBefore);
// When
renderSession();
// Then
expect(screen.getAllByText("jwt-token")).toHaveLength(2);
});
test.each([
{ name: "empty token", data: { token: "", role: "ADMIN" } },
{ name: "missing token", data: { role: "ADMIN" } },
{ name: "missing role", data: { token: "jwt-token" } },
{ name: "non ADMIN role", data: { token: "jwt-token", role: "USER" } },
])("rejects $name without storing or exposing protected content", async ({ data }) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, () =>
HttpResponse.json({ success: true, message: null, data, errorProperty: null }),
),
);
// When
renderSession();
screen.getByRole("button", { name: "로그인 실행" }).click();
// Then
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
expect(screen.queryByText("보호 콘텐츠", { exact: true })).not.toBeInTheDocument();
expect(screen.getByText("보호 콘텐츠 없음")).toBeInTheDocument();
});
test.each([
{ name: "success", response: "success" },
{ name: "non-2xx", response: "server-error" },
{ name: "network error", response: "network-error" },
])("removes local session and navigates to login after logout $name", async ({ response }) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
authSessionStorage.save({ token: "logout-token", role: "ADMIN" });
const onNavigateLogin = vi.fn();
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => {
if (response === "network-error") {
return HttpResponse.error();
}
if (response === "server-error") {
return HttpResponse.json(
{ success: false, message: "로그아웃 확인 실패", data: null, errorProperty: null },
{ status: 500 },
);
}
return HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null });
}),
);
// When
renderSession(onNavigateLogin);
screen.getByRole("button", { name: "로그아웃 실행" }).click();
// Then
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
expect(onNavigateLogin).toHaveBeenCalledExactlyOnceWith("/login");
expect(screen.getByText("보호 콘텐츠 없음")).toBeInTheDocument();
if (response === "success") {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
} else {
expect(screen.getByRole("alert")).toHaveTextContent("서버 로그아웃 확인에 실패했습니다.");
}
});
test("coalesces duplicate logout clicks while the logout request is in flight", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
authSessionStorage.save({ token: "logout-token", role: "ADMIN" });
let logoutCount = 0;
let resolveLogoutReady: (finishLogout: () => void) => void = () => undefined;
const logoutReady = new Promise<() => void>((resolve) => {
resolveLogoutReady = resolve;
});
server.use(
http.post(`${apiBaseUrl}/member/logout`, () => {
logoutCount += 1;
return new Promise((resolve) => {
resolveLogoutReady(() => resolve(HttpResponse.json({ success: true, message: null, data: {}, errorProperty: null })));
});
}),
);
// When
renderSession();
const logoutButton = screen.getByRole("button", { name: "로그아웃 실행" });
logoutButton.click();
logoutButton.click();
// Then
await waitFor(() => expect(logoutCount).toBe(1));
const finishLogout = await logoutReady;
finishLogout();
await waitFor(() => expect(authSessionStorage.read()).toBeNull());
});
});

View File

@@ -0,0 +1,89 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest";
import { LoginPage } from "@/features/auth/pages/LoginPage";
describe("LoginPage", () => {
test("renders visible email and password labels", () => {
// Given, When
render(<LoginPage onSubmit={vi.fn()} />);
// Then
expect(screen.getByLabelText("이메일")).toHaveAttribute("type", "email");
expect(screen.getByLabelText("비밀번호")).toHaveAttribute("type", "password");
});
test("renders a login notice as an alert", () => {
// Given, When
render(<LoginPage notice="세션이 만료되었습니다. 다시 로그인하세요." onSubmit={vi.fn()} />);
// Then
expect(screen.getByRole("alert")).toHaveTextContent("세션이 만료되었습니다. 다시 로그인하세요.");
});
test("connects field errors and focuses the first invalid field", async () => {
// Given
render(<LoginPage onSubmit={vi.fn()} />);
// When
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
// Then
const email = screen.getByLabelText("이메일");
const password = screen.getByLabelText("비밀번호");
const emailError = await screen.findByText("올바른 이메일을 입력하세요.");
const passwordError = screen.getByText("비밀번호를 입력하세요.");
expect(email).toHaveAccessibleDescription("올바른 이메일을 입력하세요.");
expect(password).toHaveAccessibleDescription("비밀번호를 입력하세요.");
expect(email).toHaveAttribute("aria-describedby", emailError.id);
expect(password).toHaveAttribute("aria-describedby", passwordError.id);
expect(email).toHaveFocus();
});
test("submits valid email and password", async () => {
// Given
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.resolve());
render(<LoginPage onSubmit={onSubmit} />);
// When
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
// Then
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
email: "admin@test.com",
password: "password",
}),
);
});
test("re-enables submit button when login submission fails", async () => {
// Given
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.reject(new Error("login failed")));
render(<LoginPage onSubmit={onSubmit} />);
// When
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
// Then
await waitFor(() => expect(screen.getByRole("button", { name: "로그인" })).toBeEnabled());
});
test("shows the server Korean error message when login submission fails with one", async () => {
// Given
const onSubmit = vi.fn<() => Promise<void>>(() => Promise.reject(new Error("이메일 또는 비밀번호가 올바르지 않습니다.")));
render(<LoginPage onSubmit={onSubmit} />);
// When
fireEvent.change(screen.getByLabelText("이메일"), { target: { value: "admin@test.com" } });
fireEvent.change(screen.getByLabelText("비밀번호"), { target: { value: "password" } });
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("이메일 또는 비밀번호가 올바르지 않습니다.");
});
});

View File

@@ -1,7 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/app/App";
import { queryClient } from "@/shared/api/query-client";
import "@/styles/globals.css";
import { getRuntimeEnv } from "@/shared/config/env";
getRuntimeEnv();
@@ -14,6 +17,8 @@ if (!root) {
createRoot(root).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,215 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AccessDeniedError } from "../api-error";
import { createApiClient } from "../client";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("authenticated API requests", () => {
test("sends Korean language without bearer authentication to login", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let acceptLanguage: string | null = null;
let authorization: string | null = null;
server.use(
http.post(`${apiBaseUrl}/admin/member/login`, ({ request }) => {
acceptLanguage = request.headers.get("Accept-Language");
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/admin/member/login",
method: "POST",
headers: {
Authorization: "Bearer caller-supplied-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "admin@test.com", password: "secret" }),
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(acceptLanguage).toBe("ko");
expect(authorization).toBeNull();
});
test("sends bearer authentication to a protected request", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let authorization: string | null = null;
server.use(
http.get(`${apiBaseUrl}/api/v2/protected`, ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/api/v2/protected",
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(authorization).toBe("Bearer header.payload.signature");
});
test("sends bearer authentication to logout", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
let authorization: string | null = null;
server.use(
http.post(`${apiBaseUrl}/member/logout`, ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json({ success: true, message: null, data: { value: "ok" } });
}),
);
// When
await client.request({
path: "/member/logout",
method: "POST",
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(authorization).toBe("Bearer header.payload.signature");
});
test("clears the session and redirects once for each concurrent 401 burst", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const clearSession = vi.fn();
const onAuthExpired = vi.fn();
const client = createApiClient({
getToken: () => "header.payload.signature",
clearSession,
onAuthExpired,
});
server.use(
http.get(`${apiBaseUrl}/protected`, () =>
HttpResponse.json(
{
success: false,
message: "인증 정보가 없습니다.",
data: null,
errorProperty: null,
},
{ status: 401 },
),
),
);
// When
const firstBurst = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
const secondBurst = await Promise.allSettled([
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
}),
]);
// Then
expect(firstBurst).toHaveLength(2);
expect(secondBurst).toHaveLength(2);
expect(clearSession).toHaveBeenCalledTimes(2);
expect(onAuthExpired).toHaveBeenCalledTimes(2);
});
test("surfaces access denied without clearing the session", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client, clearSession, onAuthExpired } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/protected`, () =>
HttpResponse.json(
{
success: false,
message: "접근 권한이 없습니다.",
data: null,
errorProperty: null,
},
{ status: 403 },
),
),
);
// When
const request = client.request({
path: "/protected",
responseSchema: valueSchema,
authentication: "required",
});
// Then
await expect(request).rejects.toBeInstanceOf(AccessDeniedError);
expect(clearSession).not.toHaveBeenCalled();
expect(onAuthExpired).not.toHaveBeenCalled();
});
test("does not write sensitive request data to the console", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient("jwt.secret.value");
const multipartBody = new FormData();
multipartBody.append("request", JSON.stringify({ password: "secret-password" }));
multipartBody.append("file", new File(["private-content"], "private.txt"));
const consoleDebug = vi.spyOn(console, "debug");
const consoleError = vi.spyOn(console, "error");
const consoleInfo = vi.spyOn(console, "info");
const consoleLog = vi.spyOn(console, "log");
const consoleWarn = vi.spyOn(console, "warn");
server.use(
http.post(`${apiBaseUrl}/api/v2/protected`, () =>
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
),
);
// When
await client.request({
path: "/api/v2/protected?signedUrl=https%3A%2F%2Fcdn.example.com%2Fprivate%3Fsignature%3Dabc",
method: "POST",
body: multipartBody,
responseSchema: valueSchema,
authentication: "required",
});
// Then
expect(consoleDebug).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();
expect(consoleInfo).not.toHaveBeenCalled();
expect(consoleLog).not.toHaveBeenCalled();
expect(consoleWarn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { vi } from "vitest";
import { z } from "zod";
import { createApiClient } from "../client";
export const apiBaseUrl = "https://api.example.com";
export const valueSchema = z.object({ value: z.string() });
export type TestClient = {
readonly client: ReturnType<typeof createApiClient>;
readonly clearSession: ReturnType<typeof vi.fn>;
readonly onAuthExpired: ReturnType<typeof vi.fn>;
};
export function createTestClient(token = "header.payload.signature"): TestClient {
let currentToken: string | null = token;
const clearSession = vi.fn(() => {
currentToken = null;
});
const onAuthExpired = vi.fn();
return {
client: createApiClient({
getToken: () => currentToken,
clearSession,
onAuthExpired,
}),
clearSession,
onAuthExpired,
};
}

View File

@@ -0,0 +1,117 @@
import { http, HttpResponse } from "msw";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ApiError } from "../api-error";
import { server } from "../../test/server";
import { apiBaseUrl, createTestClient, valueSchema } from "./client-test-helpers";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("API client", () => {
test("accepts a successful envelope without errorProperty", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/public`, () =>
HttpResponse.json({ success: true, message: null, data: { value: "ok" } }),
),
);
// When
const response = await client.request({
path: "/public",
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(response).toEqual({ value: "ok" });
});
test("accepts a successful envelope with null errorProperty", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/public`, () =>
HttpResponse.json({
success: true,
message: null,
data: { value: "ok" },
errorProperty: null,
}),
),
);
// When
const response = await client.request({
path: "/public",
responseSchema: valueSchema,
authentication: "none",
});
// Then
expect(response).toEqual({ value: "ok" });
});
test.each([400, 404, 405, 415, 500])(
"preserves a Korean server error envelope for status %i",
async (status) => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
const message = `서버 오류 ${status}`;
const errorProperty = "request";
server.use(
http.get(`${apiBaseUrl}/error`, () =>
HttpResponse.json(
{ success: false, message, data: null, errorProperty },
{ status },
),
),
);
// When
const request = client.request({
path: "/error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toMatchObject({ status, message, errorProperty });
},
);
test("exposes server errors as ApiError", async () => {
// Given
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
const { client } = createTestClient();
server.use(
http.get(`${apiBaseUrl}/error`, () =>
HttpResponse.json(
{
success: false,
message: "잘못된 요청입니다.",
data: null,
errorProperty: null,
},
{ status: 400 },
),
),
);
// When
const request = client.request({
path: "/error",
responseSchema: valueSchema,
authentication: "none",
});
// Then
await expect(request).rejects.toBeInstanceOf(ApiError);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import { createPageParams, type PageData } from "../pagination";
describe("createPageParams", () => {
test("uses documented default page and size", () => {
// Given
const request = {};
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams).toEqual({ page: 0, size: 20 });
});
test("clamps size to the documented lower bound", () => {
// Given
const request = { size: 1 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(20);
});
test("clamps size to the documented upper bound", () => {
// Given
const request = { size: 51 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.size).toBe(50);
});
test("keeps a provided page unchanged", () => {
// Given
const request = { page: 3, size: 20 };
// When
const pageParams = createPageParams(request);
// Then
expect(pageParams.page).toBe(3);
});
test("defines the documented page response shape", () => {
// Given
const page: PageData<string> = {
totalCount: 1,
page: 0,
size: 20,
hasNext: false,
items: ["루나"],
};
// When
const firstItem = page.items[0];
// Then
expect(firstItem).toBe("루나");
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { ApiError } from "../api-error";
import { createQueryClient, shouldRetryQuery } from "../query-client";
describe("TanStack Query defaults", () => {
test("does not retry unauthenticated query errors", () => {
// Given
const error = new ApiError({
status: 401,
message: "인증 정보가 없습니다.",
errorProperty: null,
});
// When
const shouldRetry = shouldRetryQuery(0, error);
// Then
expect(shouldRetry).toBe(false);
});
test("does not retry access-denied query errors", () => {
// Given
const error = new ApiError({
status: 403,
message: "접근 권한이 없습니다.",
errorProperty: null,
});
// When
const shouldRetry = shouldRetryQuery(0, error);
// Then
expect(shouldRetry).toBe(false);
});
test("limits retries for other query errors", () => {
// Given
const error = new Error("network failure");
// When
const shouldRetry = shouldRetryQuery(2, error);
// Then
expect(shouldRetry).toBe(false);
});
test("disables mutation retries", () => {
// Given
const queryClient = createQueryClient();
// When
const retry = queryClient.getDefaultOptions().mutations?.retry;
// Then
expect(retry).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
export type ApiErrorOptions = {
readonly status: number;
readonly message: string;
readonly errorProperty: string | null;
};
export class ApiError extends Error {
override readonly name: string = "ApiError";
readonly status: number;
readonly errorProperty: string | null;
constructor(options: ApiErrorOptions) {
super(options.message);
this.status = options.status;
this.errorProperty = options.errorProperty;
}
}
export class AccessDeniedError extends ApiError {
override readonly name: string = "AccessDeniedError";
}

131
src/shared/api/client.ts Normal file
View File

@@ -0,0 +1,131 @@
import type { z } from "zod";
import { AccessDeniedError, ApiError } from "./api-error";
import { getRuntimeEnv } from "../config/env";
import { createApiResponseSchema } from "./types";
type AuthenticationMode = "none" | "required";
export type ApiClientDependencies = {
readonly getToken: () => string | null;
readonly clearSession: () => void;
readonly onAuthExpired: () => void;
};
export type ApiRequestOptions<Data> = {
readonly path: string;
readonly responseSchema: z.ZodType<Data>;
readonly authentication: AuthenticationMode;
readonly method?: string;
readonly headers?: HeadersInit;
readonly body?: BodyInit | null;
};
export type ApiClient = {
readonly request: <Data>(options: ApiRequestOptions<Data>) => Promise<Data>;
};
function toApiError(
status: number,
response: {
readonly message: string;
readonly errorProperty: string | null;
},
): ApiError {
if (status === 403) {
return new AccessDeniedError({
status,
message: response.message,
errorProperty: response.errorProperty,
});
}
return new ApiError({
status,
message: response.message,
errorProperty: response.errorProperty,
});
}
export function createApiClient(dependencies: ApiClientDependencies): ApiClient {
let hasHandledAuthenticationExpiry = false;
let activeProtectedRequestCount = 0;
return {
async request<Data>(options: ApiRequestOptions<Data>): Promise<Data> {
const isProtectedRequest = options.authentication === "required";
if (isProtectedRequest) {
activeProtectedRequestCount += 1;
}
try {
const headers = new Headers(options.headers);
headers.set("Accept-Language", "ko");
headers.delete("Authorization");
if (isProtectedRequest) {
const token = dependencies.getToken();
if (token !== null) {
headers.set("Authorization", `Bearer ${token}`);
}
}
const init: RequestInit = { headers };
if (options.method !== undefined) {
init.method = options.method;
}
if (options.body !== undefined) {
init.body = options.body;
}
const response = await fetch(new URL(options.path, getRuntimeEnv().apiBaseUrl), init);
const parsedResponse = createApiResponseSchema(options.responseSchema).safeParse(
await response.json(),
);
if (!parsedResponse.success) {
throw new ApiError({
status: response.status,
message: "API 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
}
const apiResponse = parsedResponse.data;
if (response.ok && apiResponse.success) {
return apiResponse.data;
}
if (!apiResponse.success) {
if (response.status === 401 && isProtectedRequest) {
if (!hasHandledAuthenticationExpiry) {
hasHandledAuthenticationExpiry = true;
dependencies.clearSession();
dependencies.onAuthExpired();
}
}
throw toApiError(response.status, apiResponse);
}
throw new ApiError({
status: response.status,
message: "API 오류 응답 형식이 올바르지 않습니다.",
errorProperty: null,
});
} finally {
if (isProtectedRequest) {
activeProtectedRequestCount -= 1;
if (activeProtectedRequestCount === 0) {
hasHandledAuthenticationExpiry = false;
}
}
}
},
};
}

View File

@@ -0,0 +1,24 @@
export type PageData<Item> = {
readonly totalCount: number;
readonly page: number;
readonly size: number;
readonly hasNext: boolean;
readonly items: readonly Item[];
};
export type PageParams = {
readonly page?: number;
readonly size?: number;
};
export function createPageParams(params: PageParams = {}): {
readonly page: number;
readonly size: number;
} {
const size = params.size ?? 20;
return {
page: params.page ?? 0,
size: Math.min(Math.max(size, 20), 50),
};
}

View File

@@ -0,0 +1,22 @@
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "./api-error";
export function shouldRetryQuery(failureCount: number, error: unknown): boolean {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return false;
}
return failureCount < 2;
}
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: shouldRetryQuery },
mutations: { retry: false },
},
});
}
export const queryClient = createQueryClient();

34
src/shared/api/types.ts Normal file
View File

@@ -0,0 +1,34 @@
import { z } from "zod";
export type ApiSuccessResponse<Data> = {
readonly success: true;
readonly message: null;
readonly data: Data;
readonly errorProperty?: null;
};
export type ApiErrorResponse = {
readonly success: false;
readonly message: string;
readonly data: null;
readonly errorProperty: string | null;
};
export type ApiResponse<Data> = ApiSuccessResponse<Data> | ApiErrorResponse;
export function createApiResponseSchema<Data>(dataSchema: z.ZodType<Data>) {
return z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
message: z.null(),
data: dataSchema,
errorProperty: z.null().optional(),
}),
z.object({
success: z.literal(false),
message: z.string(),
data: z.null(),
errorProperty: z.string().nullable(),
}),
]);
}

View File

@@ -0,0 +1,12 @@
import { expect, test } from "vitest";
import { formatCanAmount, formatSeoulDateTime } from "@/shared/lib/formatters";
test("formatSeoulDateTime displays UTC input in Asia/Seoul", () => {
expect(formatSeoulDateTime("2026-07-25T15:30:00.000Z")).toBe("2026. 07. 26. 00:30");
});
test("formatCanAmount displays non-negative integer can units without domain status labels", () => {
expect(formatCanAmount(0)).toBe("0캔");
expect(formatCanAmount(12345)).toBe("12,345캔");
});

View File

@@ -0,0 +1,103 @@
import { expect, test, vi } from "vitest";
import { calculateCropOutputSize, calculateCropSourceRect, createCroppedImageFile } from "@/shared/lib/crop-image";
type RenderedCrop = {
readonly bottomAlpha: number;
readonly height: number;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly sourceX: number;
readonly sourceY: number;
readonly topAlpha: number;
readonly width: number;
};
function restoreDescriptor(property: "getContext" | "toBlob", descriptor: PropertyDescriptor | undefined): void {
if (descriptor === undefined) {
Reflect.deleteProperty(HTMLCanvasElement.prototype, property);
return;
}
Object.defineProperty(HTMLCanvasElement.prototype, property, descriptor);
}
test("calculateCropOutputSize caps width at maxWidth and keeps aspect height", () => {
expect(calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 })).toEqual({ height: 600, width: 600 });
expect(calculateCropOutputSize({ aspect: 210 / 297, maxWidth: 1000, noUpscale: true, sourceHeight: 1600, sourceWidth: 1200 })).toEqual({ height: 1414, width: 1000 });
});
test("calculateCropOutputSize never upscales when noUpscale is true", () => {
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: true, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 300, width: 600 });
expect(calculateCropOutputSize({ aspect: 2, maxWidth: 800, noUpscale: false, sourceHeight: 800, sourceWidth: 600 })).toEqual({ height: 400, width: 800 });
});
test("calculateCropSourceRect crops the largest centered source rectangle for the requested aspect", () => {
expect(calculateCropSourceRect({ aspect: 1, offsetX: 0, offsetY: 0, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 300, sourceY: 0, width: 600 });
expect(calculateCropSourceRect({ aspect: 1, offsetX: 10, offsetY: -20, sourceHeight: 600, sourceWidth: 1200, zoom: 1 })).toEqual({ height: 600, sourceX: 290, sourceY: 0, width: 600 });
});
test("createCroppedImageFile keeps a wide no-upscale crop opaque at the top and bottom", async () => {
let renderedCrop: RenderedCrop | null = null;
const imageFile = new File(["wide"], "wide.png", { type: "image/png" });
const outputSize = calculateCropOutputSize({ aspect: 1, maxWidth: 800, noUpscale: true, sourceHeight: 600, sourceWidth: 1200 });
const originalGetContext = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "getContext");
const originalToBlob = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "toBlob");
try {
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
configurable: true,
value: () => ({
drawImage: (...args: readonly unknown[]) => {
const [sourceImage, sourceX, sourceY, sourceWidth, sourceHeight, targetX, targetY, targetWidth, targetHeight] = args;
if (!(sourceImage instanceof EventTarget) || typeof sourceX !== "number" || typeof sourceY !== "number" || typeof sourceWidth !== "number" || typeof sourceHeight !== "number" || typeof targetX !== "number" || typeof targetY !== "number" || typeof targetWidth !== "number" || typeof targetHeight !== "number") {
throw new Error("Unexpected crop render call");
}
renderedCrop = {
bottomAlpha: sourceY + sourceHeight <= 600 && targetY + targetHeight <= outputSize.height ? 255 : 0,
height: targetHeight,
sourceHeight,
sourceWidth,
sourceX,
sourceY,
topAlpha: sourceY >= 0 && targetY === 0 ? 255 : 0,
width: targetWidth,
};
},
}),
});
Object.defineProperty(HTMLCanvasElement.prototype, "toBlob", {
configurable: true,
value: (callback: BlobCallback) => {
callback(new Blob([JSON.stringify(renderedCrop)], { type: imageFile.type }));
},
});
vi.stubGlobal(
"Image",
class FakeImage extends EventTarget {
set src(_value: string) {
queueMicrotask(() => this.dispatchEvent(new Event("load")));
}
},
);
const croppedFile = await createCroppedImageFile({
aspect: 1,
file: imageFile,
offsetX: 0,
offsetY: 0,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewUrl: "blob:wide",
sourceHeight: 600,
sourceWidth: 1200,
zoom: 1,
});
await expect(croppedFile.text()).resolves.toBe(JSON.stringify({ bottomAlpha: 255, height: 600, sourceHeight: 600, sourceWidth: 600, sourceX: 300, sourceY: 0, topAlpha: 255, width: 600 }));
} finally {
restoreDescriptor("getContext", originalGetContext);
restoreDescriptor("toBlob", originalToBlob);
}
});

View File

@@ -0,0 +1,101 @@
export type CropOutputSizeRequest = {
readonly aspect: number | "free";
readonly maxWidth: number;
readonly noUpscale: boolean;
readonly sourceHeight: number;
readonly sourceWidth: number;
};
export type CropOutputSize = {
readonly height: number;
readonly width: number;
};
export type CropRenderRequest = {
readonly aspect: number | "free";
readonly file: File;
readonly offsetX: number;
readonly offsetY: number;
readonly outputHeight: number;
readonly outputWidth: number;
readonly previewUrl: string;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly zoom: number;
};
export type CropSourceRectRequest = {
readonly aspect: number | "free";
readonly offsetX: number;
readonly offsetY: number;
readonly sourceHeight: number;
readonly sourceWidth: number;
readonly zoom: number;
};
export type CropSourceRect = {
readonly height: number;
readonly sourceX: number;
readonly sourceY: number;
readonly width: number;
};
function getAspect(aspect: number | "free", sourceWidth: number, sourceHeight: number): number {
return aspect === "free" ? sourceWidth / sourceHeight : aspect;
}
export function calculateCropSourceRect({ aspect, offsetX, offsetY, sourceHeight, sourceWidth, zoom }: CropSourceRectRequest): CropSourceRect {
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
const sourceAspect = sourceWidth / sourceHeight;
const baseWidth = sourceAspect > cropAspect ? Math.round(sourceHeight * cropAspect) : sourceWidth;
const baseHeight = sourceAspect > cropAspect ? sourceHeight : Math.round(sourceWidth / cropAspect);
const width = Math.round(baseWidth / zoom);
const height = Math.round(baseHeight / zoom);
const maxSourceX = sourceWidth - width;
const maxSourceY = sourceHeight - height;
const centeredX = Math.round((sourceWidth - width) / 2 - offsetX / zoom);
const centeredY = Math.round((sourceHeight - height) / 2 - offsetY / zoom);
return {
height,
sourceX: Math.min(Math.max(centeredX, 0), maxSourceX),
sourceY: Math.min(Math.max(centeredY, 0), maxSourceY),
width,
};
}
export function calculateCropOutputSize({ aspect, maxWidth, noUpscale, sourceHeight, sourceWidth }: CropOutputSizeRequest): CropOutputSize {
const cropAspect = getAspect(aspect, sourceWidth, sourceHeight);
const cropRect = calculateCropSourceRect({ aspect, offsetX: 0, offsetY: 0, sourceHeight, sourceWidth, zoom: 1 });
const width = noUpscale ? Math.min(maxWidth, cropRect.width) : maxWidth;
return { height: Math.round(width / cropAspect), width };
}
export function createCroppedImageFile(request: CropRenderRequest): Promise<File> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => {
const canvas = document.createElement("canvas");
canvas.width = request.outputWidth;
canvas.height = request.outputHeight;
const context = canvas.getContext("2d");
if (context === null) {
reject(new Error("Canvas context unavailable"));
return;
}
const cropRect = calculateCropSourceRect(request);
context.drawImage(image, cropRect.sourceX, cropRect.sourceY, cropRect.width, cropRect.height, 0, 0, request.outputWidth, request.outputHeight);
canvas.toBlob((blob) => {
if (blob === null) {
reject(new Error("Canvas result unavailable"));
return;
}
resolve(new File([blob], request.file.name, { type: request.file.type }));
}, request.file.type);
});
image.addEventListener("error", () => reject(new Error("Image preview unavailable")));
image.src = request.previewUrl;
});
}

View File

@@ -0,0 +1,23 @@
const seoulDateTimeFormatter = new Intl.DateTimeFormat("ko-KR", {
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
minute: "2-digit",
month: "2-digit",
timeZone: "Asia/Seoul",
year: "numeric",
});
const canAmountFormatter = new Intl.NumberFormat("ko-KR", {
maximumFractionDigits: 0,
});
export function formatSeoulDateTime(utcDateTime: string | Date): string {
const parts = Object.fromEntries(seoulDateTimeFormatter.formatToParts(new Date(utcDateTime)).map((part) => [part.type, part.value]));
return `${parts.year}. ${parts.month}. ${parts.day}. ${parts.hour}:${parts.minute}`;
}
export function formatCanAmount(amount: number): string {
return `${canAmountFormatter.format(Math.max(0, Math.trunc(amount)))}`;
}

View File

@@ -0,0 +1,3 @@
import { setupServer } from "msw/node";
export const server = setupServer();

View File

@@ -0,0 +1,11 @@
import { expect, test, vi } from "vitest";
test("test setup can stub a global in one test", () => {
vi.stubGlobal("indexedDB", { open: vi.fn() });
expect(indexedDB.open).toBeDefined();
});
test("test setup restores stubbed globals before the next test", () => {
expect("indexedDB" in globalThis).toBe(false);
});

View File

@@ -1,10 +1,23 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { server } from "./server";
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
vi.unstubAllGlobals();
sessionStorage.clear();
localStorage.clear();
server.resetHandlers();
});
afterAll(() => {
server.close();
});

View File

@@ -0,0 +1,117 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
import { AudioPlaybackProvider } from "@/shared/ui/audio-playback-provider";
let playSpy: ReturnType<typeof vi.spyOn>;
let pauseSpy: ReturnType<typeof vi.spyOn>;
let loadSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
pauseSpy = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
loadSpy = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined);
});
afterEach(() => {
playSpy.mockRestore();
pauseSpy.mockRestore();
loadSpy.mockRestore();
});
test("AdminAudioPlayer wraps native audio with controls and no download or autoplay", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
const audio = document.querySelector("audio");
expect(audio).toHaveAttribute("src", "https://cdn.example.com/signed/audio.m4a?token=secret");
expect(audio).not.toHaveAttribute("autoplay");
expect(audio).toHaveAttribute("controlsList", "nodownload");
expect(screen.getByRole("button", { name: "재생" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "재생 위치" })).toBeInTheDocument();
expect(screen.getByRole("slider", { name: "볼륨" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
});
test("AudioPlaybackProvider keeps only one player active by player id", () => {
render(
<AudioPlaybackProvider>
<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/one.m4a?token=secret-one" title="첫 번째" />
<AdminAudioPlayer playerId="two" src="https://cdn.example.com/signed/two.m4a?token=secret-two" title="두 번째" />
</AudioPlaybackProvider>,
);
const playButtons = screen.getAllByRole("button", { name: "재생" });
const firstPlayButton = playButtons[0];
const secondPlayButton = playButtons[1];
if (firstPlayButton === undefined || secondPlayButton === undefined) {
throw new Error("expected two play buttons");
}
fireEvent.click(firstPlayButton);
fireEvent.click(secondPlayButton);
expect(playSpy).toHaveBeenCalledTimes(2);
expect(pauseSpy).toHaveBeenCalled();
});
test("AdminAudioPlayer supports keyboard play, seek, volume, speed, generic error, and manual retry only", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" });
fireEvent.keyDown(player, { key: " " });
fireEvent.change(screen.getByRole("slider", { name: "재생 위치" }), { target: { value: "12" } });
fireEvent.change(screen.getByRole("slider", { name: "볼륨" }), { target: { value: "0.5" } });
fireEvent.change(screen.getByRole("combobox", { name: "재생 속도" }), { target: { value: "1.5" } });
const audio = document.querySelector("audio");
if (!(audio instanceof HTMLAudioElement)) {
throw new Error("expected native audio element");
}
fireEvent.error(audio);
expect(playSpy).toHaveBeenCalledTimes(1);
expect(screen.getByRole("alert")).toHaveTextContent("오디오를 재생할 수 없습니다");
fireEvent.click(screen.getByRole("button", { name: "오디오 다시 시도" }));
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(playSpy).toHaveBeenCalledTimes(1);
});
test("AdminAudioPlayer ignores Enter and Space from descendant controls", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
fireEvent.keyDown(screen.getByRole("combobox", { name: "재생 속도" }), { key: "Enter" });
fireEvent.keyDown(screen.getByRole("button", { name: "재생" }), { key: " " });
expect(playSpy).not.toHaveBeenCalled();
});
test("AudioPlaybackProvider and AdminAudioPlayer do not log or persist signed URLs", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const localStorageSpy = vi.spyOn(Storage.prototype, "setItem");
const signedUrl = "https://cdn.example.com/signed/audio.m4a?token=secret";
render(
<AudioPlaybackProvider>
<AdminAudioPlayer playerId="one" src={signedUrl} title="샘플 오디오" />
</AudioPlaybackProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "재생" }));
const audio = document.querySelector("audio");
if (!(audio instanceof HTMLAudioElement)) {
throw new Error("expected native audio element");
}
fireEvent.error(audio);
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
expect(localStorageSpy).not.toHaveBeenCalledWith(expect.any(String), expect.stringContaining(signedUrl));
logSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
localStorageSpy.mockRestore();
});

View File

@@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { expect, test, vi } from "vitest";
import { ConfirmDeactivateDialog } from "@/shared/ui/confirm-deactivate-dialog";
test("ConfirmDeactivateDialog confirms deactivation with target and impact copy, not a switch", () => {
const onCancel = vi.fn();
const onConfirm = vi.fn();
render(
<ConfirmDeactivateDialog
impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다."
onCancel={onCancel}
onConfirm={onConfirm}
open
targetName="루나"
/>,
);
expect(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" })).toHaveTextContent("사용자는 이 캐릭터를 더 이상 선택할 수 없습니다.");
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
test("ConfirmDeactivateDialog traps focus and returns it to the trigger after cancel", async () => {
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)} type="button">
</button>
<ConfirmDeactivateDialog impactDescription="사용자는 이 캐릭터를 더 이상 선택할 수 없습니다." onCancel={() => setOpen(false)} onConfirm={() => setOpen(false)} open={open} targetName="루나" />
</>
);
}
render(<Harness />);
const trigger = screen.getByRole("button", { name: "비활성화 열기" });
trigger.focus();
fireEvent.click(trigger);
const cancel = screen.getByRole("button", { name: "취소" });
const confirm = screen.getByRole("button", { name: "비활성화" });
await waitFor(() => expect(cancel).toHaveFocus());
fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab", shiftKey: true });
expect(confirm).toHaveFocus();
fireEvent.keyDown(screen.getByRole("alertdialog", { name: "루나 비활성화 확인" }), { key: "Tab" });
expect(cancel).toHaveFocus();
fireEvent.click(cancel);
await waitFor(() => expect(trigger).toHaveFocus());
});

View File

@@ -0,0 +1,41 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { FileField } from "@/shared/ui/file-field";
test("FileField exposes label, description, error, accept guidance, keyboard file input, and controlled value", () => {
const onChange = vi.fn();
const value = new File(["image"], "profile.png", { type: "image/png" });
render(
<FileField
accept="image/png"
acceptDescription="PNG만 업로드할 수 있습니다."
description="프로필 이미지를 선택하세요."
error="파일이 너무 큽니다."
label="대표 이미지"
onChange={onChange}
value={value}
/>,
);
const input = screen.getByLabelText("대표 이미지");
expect(input).toHaveAttribute("accept", "image/png");
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription("프로필 이미지를 선택하세요. PNG만 업로드할 수 있습니다. 파일이 너무 큽니다.");
expect(screen.getByText("profile.png")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "선택 취소" })).toBeInTheDocument();
});
test("FileField emits File or null and clear selection without owning upload policy", () => {
const onChange = vi.fn();
const selected = new File(["audio"], "voice.mp3", { type: "audio/mpeg" });
const { rerender } = render(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={null} />);
fireEvent.change(screen.getByLabelText("오디오"), { target: { files: [selected] } });
expect(onChange).toHaveBeenCalledWith(selected);
rerender(<FileField accept="audio/mpeg" acceptDescription="MP3" label="오디오" onChange={onChange} value={selected} />);
fireEvent.click(screen.getByRole("button", { name: "선택 취소" }));
expect(onChange).toHaveBeenCalledWith(null);
});

View File

@@ -0,0 +1,28 @@
import { readFile } from "node:fs/promises";
import { expect, test } from "vitest";
const sharedFileMediaFiles = [
"src/shared/validation/file-validation.ts",
"src/shared/validation/audio-file-policy.ts",
"src/shared/validation/image-policy.ts",
"src/shared/lib/crop-image.ts",
"src/shared/ui/file-field.tsx",
"src/shared/ui/image-crop-dialog.tsx",
"src/shared/ui/upload-progress.tsx",
"src/shared/ui/admin-audio-player.tsx",
"src/shared/ui/audio-playback-provider.tsx",
"src/shared/ui/audio-playback-context.ts",
"src/shared/ui/use-audio-playback.ts",
] as const;
test("shared file media primitives do not import endpoints, query cache, or domain DTOs", async () => {
const contents = await Promise.all(sharedFileMediaFiles.map((filePath) => readFile(filePath, "utf8")));
for (const content of contents) {
expect(content).not.toMatch(/@\/features\//);
expect(content).not.toMatch(/@tanstack\/react-query/);
expect(content).not.toMatch(/@\/shared\/api/);
expect(content).not.toMatch(/endpoint|DTO/iu);
}
});

View File

@@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import { IconOnlyAction } from "@/shared/ui/icon-only-action";
describe("IconOnlyAction", () => {
test("has an accessible name and a tooltip without duplicating the name as description", () => {
render(
<IconOnlyAction label="새로고침">
<span aria-hidden="true">R</span>
</IconOnlyAction>,
);
const button = screen.getByRole("button", { name: "새로고침" });
const tooltip = screen.getByRole("tooltip");
expect(tooltip).toHaveTextContent("새로고침");
expect(button).not.toHaveAccessibleDescription("새로고침");
expect(button).not.toHaveAttribute("aria-describedby");
expect(button).toHaveAttribute("type", "button");
});
});

View File

@@ -0,0 +1,68 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { ImageCropDialog } from "@/shared/ui/image-crop-dialog";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
const image = {
file: new File(["image"], "profile.png", { type: "image/png" }),
height: 600,
previewUrl: "blob:profile",
width: 600,
};
test("ImageCropDialog provides move, zoom, reset, preview, cancel, and apply controls", async () => {
const onApply = vi.fn();
const onCancel = vi.fn();
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([String(request.zoom)], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={image} onApply={onApply} onCancel={onCancel} open policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
fireEvent.click(screen.getByRole("button", { name: "오른쪽으로 이동" }));
fireEvent.change(screen.getByRole("slider", { name: "확대 비율" }), { target: { value: "1.5" } });
expect(screen.getByText("예상 결과 600 × 600px")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "초기화" }));
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByText("예상 결과 600 × 600px");
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 0, offsetY: 0, outputHeight: 600, outputWidth: 600, zoom: 1 }));
expect(onApply).toHaveBeenCalledWith(expect.any(File));
fireEvent.click(screen.getByRole("button", { name: "취소" }));
expect(onCancel).toHaveBeenCalled();
});
test("ImageCropDialog supports keyboard movement and no-upscale sizing", async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: 2, maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
fireEvent.keyDown(preview, { key: "ArrowRight" });
fireEvent.keyDown(preview, { key: "+" });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 600 × 300px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 10, outputHeight: 300, outputWidth: 600, zoom: 1.1 }));
});
test("ImageCropDialog supports free ratio output and pointer drag movement", async () => {
const renderCrop = vi.fn((request: CropRenderRequest) => Promise.resolve(new File([`${request.offsetX},${request.offsetY}`], "crop.png", { type: "image/png" })));
render(<ImageCropDialog image={{ ...image, height: 600, width: 1200 }} onApply={vi.fn()} onCancel={vi.fn()} open policy={{ aspect: "free", maxWidth: 800, noUpscale: true }} renderCrop={renderCrop} />);
const preview = screen.getByRole("application", { name: "이미지 crop 미리보기" });
fireEvent.pointerDown(preview, { clientX: 100, clientY: 100, pointerId: 1 });
fireEvent.pointerMove(preview, { clientX: 130, clientY: 115, pointerId: 1 });
fireEvent.pointerUp(preview, { pointerId: 1 });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
expect(await screen.findByText("예상 결과 800 × 400px")).toBeInTheDocument();
expect(renderCrop).toHaveBeenCalledWith(expect.objectContaining({ offsetX: 30, offsetY: 15, outputHeight: 400, outputWidth: 800 }));
});
test("ImageCropDialog renders nothing when closed", () => {
render(<ImageCropDialog image={image} onApply={vi.fn()} onCancel={vi.fn()} open={false} policy={{ aspect: 1, maxWidth: 800, noUpscale: true }} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { PageState } from "@/shared/ui/page-state";
test("PageState exposes accessible loading, empty, error, retry, and content states", () => {
const onRetry = vi.fn();
const { rerender } = render(<PageState description="자료를 불러오는 중입니다." state="loading" title="불러오는 중" />);
expect(screen.getByRole("status")).toHaveTextContent("불러오는 중");
rerender(<PageState description="조건에 맞는 자료가 없습니다." state="empty" title="자료 없음" />);
expect(screen.getByRole("status")).toHaveTextContent("자료 없음");
rerender(<PageState description="잠시 후 다시 시도하세요." onRetry={onRetry} state="error" title="불러오지 못했습니다" />);
expect(screen.getByRole("alert")).toHaveTextContent("불러오지 못했습니다");
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
expect(onRetry).toHaveBeenCalledTimes(1);
rerender(
<PageState state="content">
<p> </p>
</PageState>,
);
expect(screen.getByText("공유 콘텐츠")).toBeInTheDocument();
});

View File

@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import type { PageData } from "@/shared/api/pagination";
import { ResourcePagination } from "@/shared/ui/resource-pagination";
const pageData: PageData<string> = {
totalCount: 42,
page: 1,
size: 20,
hasNext: true,
items: [],
};
test("ResourcePagination uses PageData and real buttons for accessible page movement", () => {
const onPageChange = vi.fn();
const onSizeChange = vi.fn();
render(<ResourcePagination data={pageData} onPageChange={onPageChange} onSizeChange={onSizeChange} />);
const previous = screen.getByRole("button", { name: "이전 페이지" });
const next = screen.getByRole("button", { name: "다음 페이지" });
expect(previous.tagName).toBe("BUTTON");
expect(next).toBeEnabled();
expect(screen.getByText("총 42개 · 2페이지")).toBeInTheDocument();
fireEvent.click(previous);
fireEvent.click(next);
fireEvent.change(screen.getByLabelText("페이지 크기"), { target: { value: "50" } });
expect(onPageChange).toHaveBeenNthCalledWith(1, 0);
expect(onPageChange).toHaveBeenNthCalledWith(2, 2);
expect(onSizeChange).toHaveBeenCalledWith(50);
});
test("ResourcePagination disables unavailable previous and next actions", () => {
render(<ResourcePagination data={{ ...pageData, page: 0, hasNext: false }} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
expect(screen.getByRole("button", { name: "이전 페이지" })).toBeDisabled();
expect(screen.getByRole("button", { name: "다음 페이지" })).toBeDisabled();
});

View File

@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { ResponsiveResourceList } from "@/shared/ui/responsive-resource-list";
test("ResponsiveResourceList renders only desktop and mobile slots without domain props", () => {
render(
<ResponsiveResourceList
ariaLabel="공유 자료 목록"
desktop={<table><tbody><tr><td> </td></tr></tbody></table>}
mobile={<ul><li> </li></ul>}
/>,
);
expect(screen.getByRole("region", { name: "공유 자료 목록" })).toBeInTheDocument();
expect(screen.getByText("데스크톱 슬롯")).toBeInTheDocument();
expect(screen.getByText("모바일 슬롯")).toBeInTheDocument();
});

View File

@@ -0,0 +1,42 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { SearchToolbar } from "@/shared/ui/search-toolbar";
afterEach(() => {
vi.useRealTimers();
});
test("SearchToolbar keeps search controlled, renders filters, and emits only a debounced generic query", () => {
vi.useFakeTimers();
const onQueryChange = vi.fn();
const onSearchChange = vi.fn();
const { rerender } = render(
<SearchToolbar
filters={<select aria-label="상태 필터"><option></option></select>}
onQueryChange={onQueryChange}
onSearchChange={onSearchChange}
search=""
/>,
);
fireEvent.change(screen.getByRole("searchbox", { name: "검색어" }), { target: { value: "루나" } });
expect(onSearchChange).toHaveBeenCalledWith("루나");
expect(screen.getByLabelText("상태 필터")).toBeInTheDocument();
expect(onQueryChange).not.toHaveBeenCalled();
rerender(
<SearchToolbar
filters={<select aria-label="상태 필터"><option></option></select>}
onQueryChange={onQueryChange}
onSearchChange={onSearchChange}
search="루나"
/>,
);
act(() => vi.advanceTimersByTime(299));
expect(onQueryChange).not.toHaveBeenCalled();
act(() => vi.advanceTimersByTime(1));
expect(onQueryChange).toHaveBeenCalledWith("루나");
});

View File

@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { StatusBadge } from "@/shared/ui/status-badge";
describe("StatusBadge", () => {
test("renders visible Korean text labels for every state", () => {
render(
<>
<StatusBadge status="OPEN" />
<StatusBadge status="SCHEDULED" />
<StatusBadge status="INACTIVE" />
</>,
);
expect(screen.getByLabelText("상태: 공개")).toHaveTextContent("공개");
expect(screen.getByLabelText("상태: 예약")).toHaveTextContent("예약");
expect(screen.getByLabelText("상태: 비활성")).toHaveTextContent("비활성");
});
test("renders a domain label with optional icon and description without relying on color meaning", () => {
render(
<StatusBadge
description={<span> 18:00 </span>}
icon={<span aria-hidden="true">S</span>}
label="검수 대기"
tone="warning"
/>,
);
const badge = screen.getByLabelText("상태: 검수 대기, 오늘 18:00 자동 전환");
expect(badge).toHaveTextContent("검수 대기");
expect(badge).toHaveTextContent("오늘 18:00 자동 전환");
});
});

View File

@@ -0,0 +1,48 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { UnsavedChangesGuard } from "@/shared/ui/unsaved-changes-guard";
test("UnsavedChangesGuard blocks only dirty route leave and returns focus to the trigger on cancel", async () => {
const onLeave = vi.fn();
const { rerender } = render(
<UnsavedChangesGuard dirty impactDescription="저장하지 않은 변경사항이 사라집니다." title="이 화면을 떠나시겠습니까?">
{(requestRouteLeave) => (
<button onClick={(event) => requestRouteLeave(event.currentTarget, onLeave)} type="button">
</button>
)}
</UnsavedChangesGuard>,
);
const trigger = screen.getByRole("button", { name: "목록으로 이동" });
fireEvent.click(trigger);
expect(onLeave).not.toHaveBeenCalled();
const dialog = screen.getByRole("alertdialog", { name: "이 화면을 떠나시겠습니까?" });
expect(dialog).toBeInTheDocument();
const cancel = screen.getByRole("button", { name: "계속 편집" });
const leave = screen.getByRole("button", { name: "떠나기" });
await waitFor(() => expect(cancel).toHaveFocus());
fireEvent.keyDown(dialog, { key: "Tab", shiftKey: true });
expect(leave).toHaveFocus();
fireEvent.keyDown(dialog, { key: "Tab" });
expect(cancel).toHaveFocus();
fireEvent.click(cancel);
expect(trigger).toHaveFocus();
rerender(
<UnsavedChangesGuard dirty={false} impactDescription="저장하지 않은 변경사항이 사라집니다." title="이 화면을 떠나시겠습니까?">
{(requestRouteLeave) => (
<button onClick={(event) => requestRouteLeave(event.currentTarget, onLeave)} type="button">
</button>
)}
</UnsavedChangesGuard>,
);
fireEvent.click(screen.getByRole("button", { name: "목록으로 이동" }));
expect(onLeave).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,25 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { UploadProgress } from "@/shared/ui/upload-progress";
test("UploadProgress displays status and progress without owning an upload client", () => {
render(<UploadProgress fileName="voice.mp3" progress={45} status="uploading" />);
expect(screen.getByRole("progressbar", { name: "업로드 진행률" })).toHaveAttribute("aria-valuenow", "45");
expect(screen.getByText("voice.mp3")).toBeInTheDocument();
expect(screen.getByText("업로드 중")).toBeInTheDocument();
});
test("UploadProgress exposes cancel and retry callbacks only", () => {
const onCancel = vi.fn();
const onRetry = vi.fn();
render(<UploadProgress onCancel={onCancel} onRetry={onRetry} progress={0} status="error" />);
fireEvent.click(screen.getByRole("button", { name: "업로드 취소" }));
fireEvent.click(screen.getByRole("button", { name: "다시 시도" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,141 @@
import { useRef, useState } from "react";
import { useAudioPlayback } from "./use-audio-playback";
export type AdminAudioPlayerProps = {
readonly playerId: string;
readonly src: string;
readonly title: string;
};
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) {
return "0:00";
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60).toString().padStart(2, "0");
return `${minutes}:${remainingSeconds}`;
}
export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [hasError, setHasError] = useState(false);
const { clearPlayer, requestPlay } = useAudioPlayback(playerId, audioRef);
function play() {
const audio = audioRef.current;
if (audio === null) {
return;
}
setHasError(false);
requestPlay();
void audio.play().catch(() => setHasError(true));
}
function pause() {
audioRef.current?.pause();
clearPlayer();
}
function togglePlay() {
if (isPlaying) {
pause();
return;
}
play();
}
function retry() {
setHasError(false);
audioRef.current?.load();
}
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
if (event.currentTarget !== event.target) {
return;
}
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
togglePlay();
}
}
function changeCurrentTime(nextTime: number) {
const audio = audioRef.current;
setCurrentTime(nextTime);
if (audio !== null) {
audio.currentTime = nextTime;
}
}
function changeVolume(nextVolume: number) {
if (audioRef.current !== null) {
audioRef.current.volume = nextVolume;
}
}
function changePlaybackRate(nextPlaybackRate: number) {
if (audioRef.current !== null) {
audioRef.current.playbackRate = nextPlaybackRate;
}
}
return (
<section aria-label={`${title} 오디오 플레이어`} className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
<audio
controlsList="nodownload"
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
onEnded={() => {
setIsPlaying(false);
clearPlayer();
}}
onError={() => {
setHasError(true);
setIsPlaying(false);
}}
onPause={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
preload="metadata"
ref={audioRef}
src={src}
/>
<div className="flex flex-wrap items-center gap-2">
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={togglePlay} type="button">
{isPlaying ? "일시정지" : "재생"}
</button>
<span className="text-sm text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="재생 위치" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="볼륨" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
</label>
<label className="flex flex-col gap-2 text-sm font-semibold">
<select aria-label="재생 속도" className="rounded-md border border-input bg-card px-3 py-2 text-base" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))} defaultValue="1">
<option value="0.75">0.75×</option>
<option value="1">1×</option>
<option value="1.25">1.25×</option>
<option value="1.5">1.5×</option>
<option value="2">2×</option>
</select>
</label>
{hasError ? (
<div className="flex flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert">
<p> . .</p>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={retry} type="button"> </button>
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,9 @@
import { createContext } from "react";
export type AudioPlaybackContextValue = {
readonly activePlayerId: string | null;
readonly clearPlayer: (playerId: string) => void;
readonly requestPlay: (playerId: string) => void;
};
export const AudioPlaybackContext = createContext<AudioPlaybackContextValue | null>(null);

View File

@@ -0,0 +1,22 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { AudioPlaybackContext } from "./audio-playback-context";
export type AudioPlaybackProviderProps = {
readonly children: ReactNode;
};
export function AudioPlaybackProvider({ children }: AudioPlaybackProviderProps) {
const [activePlayerId, setActivePlayerId] = useState<string | null>(null);
function clearPlayer(playerId: string) {
setActivePlayerId((current) => (current === playerId ? null : current));
}
return (
<AudioPlaybackContext value={{ activePlayerId, clearPlayer, requestPlay: setActivePlayerId }}>
{children}
</AudioPlaybackContext>
);
}

View File

@@ -0,0 +1,38 @@
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type ConfirmDeactivateDialogProps = {
readonly impactDescription: string;
readonly onCancel: () => void;
readonly onConfirm: () => void;
readonly open: boolean;
readonly targetName: string;
};
export function ConfirmDeactivateDialog({ impactDescription, onCancel, onConfirm, open, targetName }: ConfirmDeactivateDialogProps) {
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
if (!open) {
return null;
}
const title = `${targetName} 비활성화 확인`;
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog" aria-label={title}>
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{impactDescription}</p>
</div>
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button">
</button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onConfirm} type="button">
</button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useId, useRef } from "react";
export type FileFieldProps = {
readonly accept: string;
readonly acceptDescription: string;
readonly description?: string;
readonly error?: string;
readonly label: string;
readonly onChange: (file: File | null) => void;
readonly value: File | null;
};
export function FileField({ accept, acceptDescription, description, error, label, onChange, value }: FileFieldProps) {
const inputId = useId();
const descriptionId = useId();
const acceptId = useId();
const errorId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const describedBy = [description === undefined ? null : descriptionId, acceptId, error === undefined ? null : errorId].filter((id): id is string => id !== null).join(" ");
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const files = event.currentTarget.files;
onChange(files === null ? null : files[0] ?? null);
}
function clearSelection() {
if (inputRef.current !== null) {
inputRef.current.value = "";
}
onChange(null);
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card p-4">
<label className="text-sm font-semibold" htmlFor={inputId}>{label}</label>
{description === undefined ? null : <p className="text-sm text-muted-foreground" id={descriptionId}>{description}</p>}
<p className="text-sm text-muted-foreground" id={acceptId}>{acceptDescription}</p>
<input accept={accept} aria-describedby={describedBy} aria-invalid={error === undefined ? undefined : true} className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={inputId} onChange={handleChange} ref={inputRef} type="file" />
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{value === null ? "선택된 파일 없음" : value.name}</span>
{value === null ? null : (
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={clearSelection} type="button"> </button>
)}
</div>
{error === undefined ? null : <p className="text-sm font-semibold text-destructive" id={errorId} role="alert">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { useId } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type IconOnlyActionProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children"> & {
readonly children: ReactNode;
readonly label: string;
};
export function IconOnlyAction({ children, className, label, type = "button", ...buttonProps }: IconOnlyActionProps) {
const tooltipId = useId();
const classes = ["icon-only-action", className].filter(Boolean).join(" ");
return (
<span className="icon-only-action-wrap">
<button {...buttonProps} aria-label={label} className={classes} type={type}>
{children}
<span className="sr-only">{label}</span>
</button>
<span className="icon-only-action-tooltip" id={tooltipId} role="tooltip">
{label}
</span>
</span>
);
}

View File

@@ -0,0 +1,153 @@
import { useRef, useState } from "react";
import { calculateCropOutputSize, createCroppedImageFile } from "@/shared/lib/crop-image";
import type { CropRenderRequest } from "@/shared/lib/crop-image";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type CropSourceImage = {
readonly file: File;
readonly height: number;
readonly previewUrl: string;
readonly width: number;
};
export type ImageCropPolicy = {
readonly aspect: number | "free";
readonly maxWidth: number;
readonly noUpscale: boolean;
};
export type ImageCropDialogProps = {
readonly image: CropSourceImage;
readonly onApply: (file: File) => void;
readonly onCancel: () => void;
readonly open: boolean;
readonly policy: ImageCropPolicy;
readonly renderCrop?: (request: CropRenderRequest) => Promise<File>;
};
const MOVE_STEP = 10;
const ZOOM_STEP = 0.1;
export function ImageCropDialog({ image, onApply, onCancel, open, policy, renderCrop = createCroppedImageFile }: ImageCropDialogProps) {
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
const [zoom, setZoom] = useState(1);
const dragPointRef = useRef<{ readonly x: number; readonly y: number } | null>(null);
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(open);
const outputSize = calculateCropOutputSize({ aspect: policy.aspect, maxWidth: policy.maxWidth, noUpscale: policy.noUpscale, sourceHeight: image.height, sourceWidth: image.width });
if (!open) {
return null;
}
function resetCrop() {
setOffsetX(0);
setOffsetY(0);
setZoom(1);
}
function move(deltaX: number, deltaY: number) {
setOffsetX((current) => current + deltaX);
setOffsetY((current) => current + deltaY);
}
function changeZoom(nextZoom: number) {
setZoom(Math.min(3, Math.max(1, Number(nextZoom.toFixed(1)))));
}
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(0, MOVE_STEP);
return;
case "ArrowLeft":
event.preventDefault();
move(-MOVE_STEP, 0);
return;
case "ArrowRight":
event.preventDefault();
move(MOVE_STEP, 0);
return;
case "ArrowUp":
event.preventDefault();
move(0, -MOVE_STEP);
return;
case "+":
event.preventDefault();
changeZoom(zoom + ZOOM_STEP);
return;
case "-":
event.preventDefault();
changeZoom(zoom - ZOOM_STEP);
return;
default:
}
}
function startDrag(event: React.PointerEvent<HTMLElement>) {
dragPointRef.current = { x: event.clientX, y: event.clientY };
event.currentTarget.setPointerCapture?.(event.pointerId);
}
function drag(event: React.PointerEvent<HTMLElement>) {
const dragPoint = dragPointRef.current;
if (dragPoint === null) {
return;
}
move(event.clientX - dragPoint.x, event.clientY - dragPoint.y);
dragPointRef.current = { x: event.clientX, y: event.clientY };
}
function stopDrag() {
dragPointRef.current = null;
}
async function applyCrop() {
const file = await renderCrop({
aspect: policy.aspect,
file: image.file,
offsetX,
offsetY,
outputHeight: outputSize.height,
outputWidth: outputSize.width,
previewUrl: image.previewUrl,
sourceHeight: image.height,
sourceWidth: image.width,
zoom,
});
onApply(file);
}
return (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label="이미지 crop" aria-modal="true" className="flex w-full max-w-lg flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="dialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold"> crop</h2>
<p className="text-sm text-muted-foreground">, , .</p>
</div>
<div aria-label="이미지 crop 미리보기" className="overflow-hidden rounded-lg border border-border bg-muted p-4" onKeyDown={handleKeyDown} onPointerDown={startDrag} onPointerLeave={stopDrag} onPointerMove={drag} onPointerUp={stopDrag} role="application" tabIndex={0}>
<img alt="선택한 이미지 미리보기" className="mx-auto max-h-64 max-w-full" src={image.previewUrl} style={{ transform: `translate(${offsetX}px, ${offsetY}px) scale(${zoom})` }} />
</div>
<p className="text-sm font-semibold text-info"> {outputSize.width} × {outputSize.height}px</p>
<div className="grid gap-2 sm:grid-cols-2">
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, -MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(0, MOVE_STEP)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(-MOVE_STEP, 0)} type="button"> </button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={() => move(MOVE_STEP, 0)} type="button"> </button>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<input aria-label="확대 비율" max="3" min="1" onChange={(event) => changeZoom(Number(event.currentTarget.value))} step="0.1" type="range" value={zoom} />
</label>
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={resetCrop} type="button"></button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={() => void applyCrop()} type="button"></button>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import type { ReactNode } from "react";
type LoadingPageStateProps = {
readonly description?: string;
readonly state: "loading";
readonly title: string;
};
type EmptyPageStateProps = {
readonly description?: string;
readonly state: "empty";
readonly title: string;
};
type ErrorPageStateProps = {
readonly description?: string;
readonly onRetry?: () => void;
readonly state: "error";
readonly title: string;
};
type ContentPageStateProps = {
readonly children: ReactNode;
readonly state: "content";
};
export type PageStateProps = LoadingPageStateProps | EmptyPageStateProps | ErrorPageStateProps | ContentPageStateProps;
function assertNever(value: never): never {
throw new Error(`Unhandled page state: ${String(value)}`);
}
export function PageState(props: PageStateProps) {
switch (props.state) {
case "content":
return <>{props.children}</>;
case "loading":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "empty":
return (
<section className="rounded-lg border border-border bg-card p-6" role="status">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm text-muted-foreground">{props.description}</p>}
</section>
);
case "error":
return (
<section className="rounded-lg border border-destructive bg-card p-6 text-destructive" role="alert">
<h2 className="text-xl font-semibold">{props.title}</h2>
{props.description === undefined ? null : <p className="mt-2 text-sm">{props.description}</p>}
{props.onRetry === undefined ? null : (
<button className="mt-4 rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={props.onRetry} type="button">
</button>
)}
</section>
);
default:
return assertNever(props);
}
}

View File

@@ -0,0 +1,32 @@
import type { PageData } from "@/shared/api/pagination";
export type ResourcePaginationProps = {
readonly data: PageData<unknown>;
readonly onPageChange: (page: number) => void;
readonly onSizeChange: (size: number) => void;
readonly sizeOptions?: readonly number[];
};
export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptions = [20, 50] }: ResourcePaginationProps) {
return (
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p>
<div className="flex flex-wrap items-center gap-2">
<label className="text-sm font-semibold" htmlFor="resource-page-size">
</label>
<select className="rounded-md border border-input bg-card px-3 py-2 text-base" id="resource-page-size" onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
{sizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
</button>
<button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
</button>
</div>
</nav>
);
}

View File

@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
export type ResponsiveResourceListProps = {
readonly ariaLabel: string;
readonly desktop: ReactNode;
readonly mobile: ReactNode;
};
export function ResponsiveResourceList({ ariaLabel, desktop, mobile }: ResponsiveResourceListProps) {
return (
<section aria-label={ariaLabel} className="rounded-lg border border-border bg-card" role="region">
<div className="hidden overflow-x-auto md:block">{desktop}</div>
<div className="md:hidden">{mobile}</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
export type SearchToolbarProps = {
readonly filters?: ReactNode;
readonly onQueryChange: (query: string) => void;
readonly onSearchChange: (search: string) => void;
readonly search: string;
};
export function SearchToolbar({ filters, onQueryChange, onSearchChange, search }: SearchToolbarProps) {
const searchId = useId();
const didMountRef = useRef(false);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return undefined;
}
const timeoutId = window.setTimeout(() => onQueryChange(search), 300);
return () => window.clearTimeout(timeoutId);
}, [onQueryChange, search]);
return (
<section aria-label="검색 도구" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<label className="text-sm font-semibold" htmlFor={searchId}>
</label>
<input className="rounded-md border border-input bg-card px-3 py-2 text-base text-foreground" id={searchId} onChange={(event) => onSearchChange(event.currentTarget.value)} type="search" value={search} />
</div>
{filters === undefined ? null : <div className="flex flex-col gap-2 sm:min-w-48">{filters}</div>}
</section>
);
}

View File

@@ -0,0 +1,91 @@
import { isValidElement } from "react";
import type { ReactNode } from "react";
const STATUS_BADGE = {
INACTIVE: {
className: "status-badge status-badge--inactive",
label: "비활성",
tone: "inactive",
},
OPEN: {
className: "status-badge status-badge--success",
label: "공개",
tone: "success",
},
SCHEDULED: {
className: "status-badge status-badge--warning",
label: "예약",
tone: "warning",
},
} as const;
export type StatusBadgeStatus = keyof typeof STATUS_BADGE;
export type StatusBadgeTone = "inactive" | "success" | "warning";
type PresetStatusBadgeProps = {
readonly status: StatusBadgeStatus;
readonly description?: never;
readonly icon?: never;
readonly label?: never;
readonly tone?: never;
};
type DomainStatusBadgeProps = {
readonly description?: ReactNode;
readonly icon?: ReactNode;
readonly label: string;
readonly status?: never;
readonly tone: StatusBadgeTone;
};
export type StatusBadgeProps = PresetStatusBadgeProps | DomainStatusBadgeProps;
function getNodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getNodeText).join("");
}
if (isValidElement<{ readonly children?: ReactNode }>(node)) {
return getNodeText(node.props.children);
}
return "";
}
function getBadge(props: StatusBadgeProps): {
readonly className: string;
readonly description: ReactNode;
readonly label: string;
readonly icon: ReactNode;
} {
if (props.status !== undefined) {
const badge = STATUS_BADGE[props.status];
return { className: badge.className, description: null, icon: null, label: badge.label };
}
return {
className: `status-badge status-badge--${props.tone}`,
description: props.description ?? null,
icon: props.icon ?? null,
label: props.label,
};
}
export function StatusBadge(props: StatusBadgeProps) {
const badge = getBadge(props);
const descriptionText = getNodeText(badge.description);
const ariaLabel = descriptionText.length > 0 ? `상태: ${badge.label}, ${descriptionText}` : `상태: ${badge.label}`;
return (
<span aria-label={ariaLabel} className={badge.className}>
{badge.icon ?? <span aria-hidden="true" className="status-badge__dot" />}
{badge.label}
{badge.description === null ? null : <span className="font-normal">{badge.description}</span>}
</span>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { useModalFocus } from "@/shared/ui/use-modal-focus";
export type RequestRouteLeave = (trigger: HTMLElement, leaveRoute: () => void) => void;
export type UnsavedChangesGuardProps = {
readonly children: (requestRouteLeave: RequestRouteLeave) => ReactNode;
readonly dirty: boolean;
readonly impactDescription: string;
readonly title: string;
};
type PendingRouteLeave = {
readonly leaveRoute: () => void;
readonly trigger: HTMLElement;
};
export function UnsavedChangesGuard({ children, dirty, impactDescription, title }: UnsavedChangesGuardProps) {
const [pendingRouteLeave, setPendingRouteLeave] = useState<PendingRouteLeave | null>(null);
const { dialogRef, trapFocus } = useModalFocus<HTMLElement>(pendingRouteLeave !== null);
function closeDialog() {
const trigger = pendingRouteLeave?.trigger;
setPendingRouteLeave(null);
trigger?.focus();
}
function confirmLeave() {
const leaveRoute = pendingRouteLeave?.leaveRoute;
setPendingRouteLeave(null);
leaveRoute?.();
}
function requestRouteLeave(trigger: HTMLElement, leaveRoute: () => void) {
if (!dirty) {
leaveRoute();
return;
}
setPendingRouteLeave({ leaveRoute, trigger });
}
return (
<>
{children(requestRouteLeave)}
{dirty && pendingRouteLeave !== null ? (
<div className="fixed inset-0 z-modal grid place-items-center bg-background/80 p-4">
<section aria-label={title} aria-modal="true" className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6" onKeyDown={trapFocus} ref={dialogRef} role="alertdialog">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{impactDescription}</p>
</div>
<div className="flex justify-end gap-2">
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={closeDialog} type="button">
</button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={confirmLeave} type="button">
</button>
</div>
</section>
</div>
) : null}
</>
);
}

View File

@@ -0,0 +1,39 @@
const STATUS_LABEL = {
canceled: "취소됨",
error: "업로드 실패",
idle: "대기 중",
success: "업로드 완료",
uploading: "업로드 중",
} as const;
export type UploadProgressStatus = keyof typeof STATUS_LABEL;
export type UploadProgressProps = {
readonly fileName?: string;
readonly onCancel?: () => void;
readonly onRetry?: () => void;
readonly progress: number;
readonly status: UploadProgressStatus;
};
export function UploadProgress({ fileName, onCancel, onRetry, progress, status }: UploadProgressProps) {
const safeProgress = Math.min(100, Math.max(0, Math.round(progress)));
return (
<section aria-label="업로드 상태" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-col gap-1">
{fileName === undefined ? null : <p className="text-sm font-semibold">{fileName}</p>}
<p className="text-sm text-muted-foreground">{STATUS_LABEL[status]}</p>
</div>
<div className="flex gap-2">
{onCancel === undefined ? null : <button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={onCancel} type="button"> </button>}
{onRetry === undefined ? null : <button className="rounded-md border border-input bg-primary px-3 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={onRetry} type="button"> </button>}
</div>
</div>
<div aria-label="업로드 진행률" aria-valuemax={100} aria-valuemin={0} aria-valuenow={safeProgress} className="h-3 overflow-hidden rounded-sm bg-muted" role="progressbar">
<div className="h-full bg-primary" style={{ inlineSize: `${safeProgress}%` }} />
</div>
</section>
);
}

View File

@@ -0,0 +1,19 @@
import { useContext, useEffect } from "react";
import type { RefObject } from "react";
import { AudioPlaybackContext } from "./audio-playback-context";
export function useAudioPlayback(playerId: string, audioRef: RefObject<HTMLAudioElement | null>) {
const context = useContext(AudioPlaybackContext);
useEffect(() => {
if (context?.activePlayerId !== null && context?.activePlayerId !== undefined && context.activePlayerId !== playerId) {
audioRef.current?.pause();
}
}, [audioRef, context?.activePlayerId, playerId]);
return {
clearPlayer: () => context?.clearPlayer(playerId),
requestPlay: () => context?.requestPlay(playerId),
};
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from "react";
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
export function useModalFocus<T extends HTMLElement>(open: boolean) {
const dialogRef = useRef<T>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) {
return undefined;
}
triggerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusableElements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
focusableElements[0]?.focus();
return () => {
triggerRef.current?.focus();
triggerRef.current = null;
};
}, [open]);
function trapFocus(event: React.KeyboardEvent<T>) {
if (event.key !== "Tab") {
return;
}
const focusableElements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
const firstElement = focusableElements[0];
const lastElement = focusableElements.at(-1);
if (firstElement === undefined || lastElement === undefined) {
return;
}
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
return;
}
if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
return { dialogRef, trapFocus };
}

View File

@@ -0,0 +1,31 @@
import { getFileExtension, validateFile } from "./file-validation";
import type { FileValidationResult } from "./file-validation";
export const AUDIO_FILE_POLICY = {
allowedExtensions: [".mp3", ".aac", ".m4a"],
allowedMimeTypes: ["audio/mpeg", "audio/aac", "audio/mp4", "audio/x-m4a"],
maxBytes: 1_024_000_000,
} as const;
const allowedMimeByExtension = {
".aac": ["audio/aac"],
".m4a": ["audio/mp4", "audio/x-m4a"],
".mp3": ["audio/mpeg"],
} satisfies Record<string, readonly string[]>;
export type AudioFileValidationResult = FileValidationResult | { readonly ok: false; readonly reason: "mimeExtensionCombination" };
export function validateAudioFile(file: File): AudioFileValidationResult {
const baseResult = validateFile(file, AUDIO_FILE_POLICY);
if (!baseResult.ok) {
return baseResult;
}
const extension = getFileExtension(file.name);
const allowedMimeTypes = allowedMimeByExtension[extension as keyof typeof allowedMimeByExtension];
if (allowedMimeTypes === undefined || !allowedMimeTypes.includes(file.type)) {
return { ok: false, reason: "mimeExtensionCombination" };
}
return { ok: true };
}

View File

@@ -0,0 +1,93 @@
import { expect, test, vi } from "vitest";
import { createImagePolicy, IMAGE_MAX_BYTES } from "@/shared/validation/image-policy";
import { AUDIO_FILE_POLICY, validateAudioFile } from "@/shared/validation/audio-file-policy";
import { validateFile } from "@/shared/validation/file-validation";
function fileWithSize(name: string, type: string, size: number): File {
const file = new File(["x"], name, { type });
Object.defineProperty(file, "size", { value: size });
return file;
}
test("validateFile checks injected extension, MIME, and maxBytes together", () => {
const policy = {
allowedExtensions: [".png"] as const,
allowedMimeTypes: ["image/png"] as const,
maxBytes: 10,
};
expect(validateFile(fileWithSize("cover.png", "image/png", 10), policy)).toEqual({ ok: true });
expect(validateFile(fileWithSize("cover.jpg", "image/png", 10), policy)).toEqual({ ok: false, reason: "extension" });
expect(validateFile(fileWithSize("cover.png", "image/jpeg", 10), policy)).toEqual({ ok: false, reason: "mime" });
expect(validateFile(fileWithSize("cover.png", "image/png", 11), policy)).toEqual({ ok: false, reason: "size" });
});
test("validateFile lets callers inject a 10MB byte boundary without owning image domain policy", () => {
const tenMegabytes = 10 * 1024 * 1024;
const policy = {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: tenMegabytes,
};
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes), policy)).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", tenMegabytes + 1), policy)).toEqual({ ok: false, reason: "size" });
});
test("validateAudioFile accepts MP3, AAC, M4A including audio/x-m4a at 1,024,000,000 bytes", () => {
expect(AUDIO_FILE_POLICY.maxBytes).toBe(1_024_000_000);
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/aac", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/mp4", 1_024_000_000))).toEqual({ ok: true });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/x-m4a", 1_024_000_000))).toEqual({ ok: true });
});
test("validateAudioFile rejects WAV, oversized files, and audio/x-m4a without .m4a without sniffing codecs", async () => {
const wav = fileWithSize("voice.wav", "audio/wav", 10);
const sniff = vi.spyOn(wav, "arrayBuffer");
expect(validateAudioFile(wav)).toEqual({ ok: false, reason: "extension" });
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mpeg", 1_024_000_001))).toEqual({ ok: false, reason: "size" });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/x-m4a", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(sniff).not.toHaveBeenCalled();
});
test("validateAudioFile rejects mismatched canonical MIME and extension combinations", () => {
expect(validateAudioFile(fileWithSize("voice.mp3", "audio/mp4", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.aac", "audio/mpeg", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
expect(validateAudioFile(fileWithSize("voice.m4a", "audio/aac", 10))).toEqual({ ok: false, reason: "mimeExtensionCombination" });
});
test("createImagePolicy records only a domain-neutral crop contract", () => {
expect(createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true })).toEqual({
aspect: 1,
cropRequired: true,
maxBytes: 10_485_760,
maxWidth: 800,
noUpscale: true,
});
});
test("createImagePolicy records the confirmed 10MiB image byte boundary", () => {
const policy = createImagePolicy({ aspect: 1, cropRequired: true, maxWidth: 800, noUpscale: true });
expect(IMAGE_MAX_BYTES).toBe(10_485_760);
expect(policy.maxBytes).toBe(10_485_760);
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_759), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_760), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: true });
expect(validateFile(fileWithSize("profile.jpg", "image/jpeg", 10_485_761), {
allowedExtensions: [".jpg"] as const,
allowedMimeTypes: ["image/jpeg"] as const,
maxBytes: policy.maxBytes,
})).toEqual({ ok: false, reason: "size" });
});

View File

@@ -0,0 +1,31 @@
export type FileValidationPolicy = {
readonly allowedExtensions: readonly string[];
readonly allowedMimeTypes: readonly string[];
readonly maxBytes: number;
};
export type FileValidationResult =
| { readonly ok: true }
| { readonly ok: false; readonly reason: "extension" | "mime" | "size" };
export function getFileExtension(fileName: string): string {
const dotIndex = fileName.lastIndexOf(".");
return dotIndex < 0 ? "" : fileName.slice(dotIndex).toLowerCase();
}
export function validateFile(file: File, policy: FileValidationPolicy): FileValidationResult {
if (file.size > policy.maxBytes) {
return { ok: false, reason: "size" };
}
if (!policy.allowedExtensions.includes(getFileExtension(file.name))) {
return { ok: false, reason: "extension" };
}
if (!policy.allowedMimeTypes.includes(file.type)) {
return { ok: false, reason: "mime" };
}
return { ok: true };
}

View File

@@ -0,0 +1,15 @@
export type ImageAspect = number | "free";
export const IMAGE_MAX_BYTES = 10_485_760;
export type ImagePolicy = {
readonly aspect: ImageAspect;
readonly cropRequired: boolean;
readonly maxBytes: number;
readonly maxWidth: number;
readonly noUpscale: boolean;
};
export function createImagePolicy(policy: Omit<ImagePolicy, "maxBytes">): ImagePolicy {
return { ...policy, maxBytes: IMAGE_MAX_BYTES };
}

View File

@@ -0,0 +1,165 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const rootDir = process.cwd();
function projectFile(path: string) {
const absolutePath = join(rootDir, path);
expect(existsSync(absolutePath)).toBe(true);
return readFileSync(absolutePath, "utf8");
}
function cssSource() {
return projectFile("src/styles/globals.css");
}
function cssVariable(css: string, name: string) {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const matches = [...css.matchAll(new RegExp(`(?:^|[\\s{])--${escapedName}:\\s*([^;]+);`, "gm"))];
const value = matches.at(-1)?.[1]?.trim();
if (!value) {
throw new Error(`Missing --${name}`);
}
return resolveCssVariable(css, value).toUpperCase();
}
function resolveCssVariable(css: string, value: string): string {
const variableName = /^var\(--([^)]+)\)$/.exec(value.trim())?.[1];
if (!variableName) {
return value;
}
return cssVariable(css, variableName);
}
function relativeLuminance(channel: number) {
const normalized = channel / 255;
if (normalized <= 0.03928) {
return normalized / 12.92;
}
return ((normalized + 0.055) / 1.055) ** 2.4;
}
function hexToRgb(hex: string) {
const value = hex.replace("#", "");
return {
blue: Number.parseInt(value.slice(4, 6), 16),
green: Number.parseInt(value.slice(2, 4), 16),
red: Number.parseInt(value.slice(0, 2), 16),
};
}
function contrastRatio(foreground: string, background: string) {
const fg = hexToRgb(foreground);
const bg = hexToRgb(background);
const fgLuminance = 0.2126 * relativeLuminance(fg.red) + 0.7152 * relativeLuminance(fg.green) + 0.0722 * relativeLuminance(fg.blue);
const bgLuminance = 0.2126 * relativeLuminance(bg.red) + 0.7152 * relativeLuminance(bg.green) + 0.0722 * relativeLuminance(bg.blue);
const lighter = Math.max(fgLuminance, bgLuminance);
const darker = Math.min(fgLuminance, bgLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
describe("Task 1.1 design system tokens", () => {
test("wires PRD brand tokens through CSS variable mode", () => {
const css = cssSource();
const componentsJson = projectFile("components.json");
const main = projectFile("src/main.tsx");
const design = projectFile("DESIGN.md");
expect(main).toContain('import "@/styles/globals.css";');
expect(componentsJson).toContain('"cssVariables": true');
expect(componentsJson).toContain('"css": "src/styles/globals.css"');
expect(css).toContain("@import \"tailwindcss\"");
expect(cssVariable(css, "color-brand-500")).toBe("#00BDF7");
expect(cssVariable(css, "primary")).toBe("#00BDF7");
expect(cssVariable(css, "primary-foreground")).toBe("#062B36");
expect(cssVariable(css, "button-bg")).toBe("#00BDF7");
expect(design).toContain("--color-brand-500");
expect(design).toContain("--primary");
});
test("keeps required token contrast and blocks white text on primary", () => {
const css = cssSource();
const primary = cssVariable(css, "color-brand-500");
const primaryForeground = cssVariable(css, "color-primary-foreground");
const background = cssVariable(css, "background");
const card = cssVariable(css, "color-card");
const info = cssVariable(css, "info");
const input = cssVariable(css, "color-input");
const ring = cssVariable(css, "color-brand-800");
expect(contrastRatio(primaryForeground, primary)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio("#FFFFFF", primary)).toBeLessThan(4.5);
expect(contrastRatio(info, background)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(input, card)).toBeGreaterThanOrEqual(3);
expect(contrastRatio(ring, card)).toBeGreaterThanOrEqual(3);
});
test("keeps core foreground contrast against page and card surfaces", () => {
const css = cssSource();
const foreground = cssVariable(css, "foreground");
const background = cssVariable(css, "background");
const card = cssVariable(css, "card");
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(foreground, card)).toBeGreaterThanOrEqual(4.5);
});
test("keeps the theme light-only", () => {
const source = [
projectFile("src/main.tsx"),
projectFile("src/app/App.tsx"),
cssSource(),
projectFile("components.json"),
projectFile("package.json"),
].join("\n");
expect(source).not.toMatch(/\.dark\b/);
expect(source).not.toContain("ThemeProvider");
expect(source).not.toContain("theme toggle");
expect(source).not.toContain("next-themes");
expect(source).not.toContain("prefers-color-scheme");
});
test("defines Korean admin base styles and accessibility primitives", () => {
const css = cssSource();
expect(cssVariable(css, "font-sans")).toBe('PRETENDARD, "NOTO SANS KR", "APPLE SD GOTHIC NEO", SYSTEM-UI, SANS-SERIF');
expect(css).toContain("--target-control-min: 2.75rem");
expect(css).toContain("--focus-ring-width: 0.125rem");
expect(css).toContain("--z-sticky: 10");
expect(css).toContain("--z-navigation: 20");
expect(css).toContain("--z-popover: 30");
expect(css).toContain("--z-overlay: 40");
expect(css).toContain("--z-modal: 50");
expect(css).toMatch(/input,\s*select,\s*textarea\s*{[^}]*font-size:\s*max\(1rem, var\(--font-size-body\)\)/s);
expect(css).toMatch(/button,\s*\[role="button"\],\s*input,\s*select,\s*textarea\s*{[^}]*min-block-size:\s*var\(--target-control-min\)/s);
expect(css).toContain(":focus-visible");
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
expect(css).toContain(".icon-only-action");
expect(css).toContain("border: 1px solid var(--input)");
});
test("exposes declared semantic status and link tokens to Tailwind", () => {
const css = cssSource();
expect(css).toContain("--color-success: var(--success)");
expect(css).toContain("--color-success-surface: var(--success-surface)");
expect(css).toContain("--color-warning: var(--warning)");
expect(css).toContain("--color-warning-surface: var(--warning-surface)");
expect(css).toContain("--color-inactive: var(--inactive)");
expect(css).toContain("--color-inactive-surface: var(--inactive-surface)");
expect(css).toContain("--color-link: var(--link)");
expect(css).toContain("--color-link-hover: var(--link-hover)");
expect(css).toContain("--color-info: var(--info)");
});
});

289
src/styles/globals.css Normal file
View File

@@ -0,0 +1,289 @@
@import "tailwindcss";
@theme inline {
--font-sans: var(--font-sans);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-success-surface: var(--success-surface);
--color-warning: var(--warning);
--color-warning-surface: var(--warning-surface);
--color-inactive: var(--inactive);
--color-inactive-surface: var(--inactive-surface);
--color-link: var(--link);
--color-link-hover: var(--link-hover);
--color-info: var(--info);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--z-sticky: var(--z-sticky);
--z-navigation: var(--z-navigation);
--z-popover: var(--z-popover);
--z-overlay: var(--z-overlay);
--z-modal: var(--z-modal);
}
:root {
--color-brand-50: #F0FBFF;
--color-brand-100: #D9F6FF;
--color-brand-200: #B5EEFF;
--color-brand-300: #7CE2FF;
--color-brand-400: #36D1FF;
--color-brand-500: #00BDF7;
--color-brand-600: #00A9DE;
--color-brand-700: #009DCE;
--color-brand-800: #007EA8;
--color-brand-900: #086789;
--color-brand-950: #063747;
--color-background: #F6FBFD;
--color-card: #FFFFFF;
--color-foreground: #102A33;
--color-muted: #E9F4F7;
--color-muted-foreground: #425F69;
--color-secondary: #E1F5FA;
--color-secondary-foreground: #123E4B;
--color-accent: #D9F6FF;
--color-accent-foreground: #0C566F;
--color-border: #D5E8EE;
--color-input: #577581;
--color-primary-foreground: #062B36;
--color-success: #167347;
--color-success-surface: #EAF8F0;
--color-warning: #9A5B00;
--color-warning-surface: #FFF7E6;
--color-destructive: #B42318;
--color-destructive-surface: #FEF0EE;
--color-inactive: #52636A;
--color-inactive-surface: #EEF3F5;
--background: var(--color-background);
--foreground: var(--color-foreground);
--card: var(--color-card);
--card-foreground: var(--color-foreground);
--popover: var(--color-card);
--popover-foreground: var(--color-foreground);
--primary: var(--color-brand-500);
--primary-hover: var(--color-brand-600);
--primary-active: var(--color-brand-700);
--primary-foreground: var(--color-primary-foreground);
--secondary: var(--color-secondary);
--secondary-foreground: var(--color-secondary-foreground);
--muted: var(--color-muted);
--muted-foreground: var(--color-muted-foreground);
--accent: var(--color-accent);
--accent-foreground: var(--color-accent-foreground);
--destructive: var(--color-destructive);
--border: var(--color-border);
--input: var(--color-input);
--ring: var(--color-brand-800);
--link: var(--color-brand-800);
--link-hover: var(--color-brand-900);
--info: var(--color-brand-900);
--success: var(--color-success);
--success-surface: var(--color-success-surface);
--warning: var(--color-warning);
--warning-surface: var(--color-warning-surface);
--inactive: var(--color-inactive);
--inactive-surface: var(--color-inactive-surface);
--font-sans: Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", system-ui, sans-serif;
--font-size-page-title: 1.5rem;
--font-size-section-title: 1.25rem;
--font-size-body: 0.875rem;
--font-size-small: 0.8125rem;
--font-size-caption: 0.75rem;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius: var(--radius-md);
--target-control-min: 2.75rem;
--focus-ring-width: 0.125rem;
--duration-micro: 150ms;
--z-sticky: 10;
--z-navigation: 20;
--z-popover: 30;
--z-overlay: 40;
--z-modal: 50;
--button-bg: var(--primary);
--button-bg-hover: var(--primary-hover);
--button-bg-active: var(--primary-active);
--button-fg: var(--primary-foreground);
--button-radius: var(--radius-md);
--button-border: var(--input);
--badge-radius: var(--radius-sm);
}
* {
box-sizing: border-box;
}
html {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-size: 100%;
}
body {
min-block-size: 100dvb;
margin: 0;
background: var(--background);
color: var(--foreground);
font-size: var(--font-size-body);
line-height: 1.5;
text-rendering: optimizeLegibility;
}
button,
[role="button"],
input,
select,
textarea {
min-block-size: var(--target-control-min);
}
input,
select,
textarea {
font: inherit;
font-size: max(1rem, var(--font-size-body));
}
button {
font: inherit;
}
:focus-visible {
outline: var(--focus-ring-width) solid var(--ring);
outline-offset: 0.125rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
border: 1px solid currentColor;
border-radius: var(--badge-radius);
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-caption);
font-weight: 700;
line-height: 1.4;
}
.status-badge__dot {
inline-size: 0.5rem;
block-size: 0.5rem;
border-radius: 999px;
background: currentColor;
}
.status-badge--success {
background: var(--success-surface);
color: var(--success);
}
.status-badge--warning {
background: var(--warning-surface);
color: var(--warning);
}
.status-badge--inactive {
background: var(--inactive-surface);
color: var(--inactive);
}
.icon-only-action-wrap {
position: relative;
display: inline-flex;
}
.icon-only-action {
display: inline-grid;
place-items: center;
min-inline-size: var(--target-control-min);
border: 1px solid var(--input);
border-radius: var(--button-radius);
background: var(--card);
color: var(--foreground);
cursor: pointer;
transition: background-color var(--duration-micro) ease-out, color var(--duration-micro) ease-out, transform var(--duration-micro) ease-out;
}
.icon-only-action:hover {
background: var(--accent);
color: var(--accent-foreground);
}
.icon-only-action:active {
transform: translateY(1px);
}
.icon-only-action:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.icon-only-action-tooltip {
position: absolute;
inset-block-end: calc(100% + var(--space-1));
inset-inline-start: 50%;
z-index: var(--z-popover);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
background: var(--foreground);
color: var(--card);
font-size: var(--font-size-caption);
opacity: 0;
pointer-events: none;
transform: translateX(-50%);
transition: opacity var(--duration-micro) ease-out;
white-space: nowrap;
}
.icon-only-action:hover + .icon-only-action-tooltip,
.icon-only-action:focus-visible + .icon-only-action-tooltip {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto;
transition-duration: 0.01ms;
animation-duration: 0.01ms;
animation-iteration-count: 1;
}
}