feat: centralize routes and navigation guards
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
|
"react-router-dom": "7.18.1",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+39
@@ -17,6 +17,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.8
|
specifier: 19.2.8
|
||||||
version: 19.2.8(react@19.2.8)
|
version: 19.2.8(react@19.2.8)
|
||||||
|
react-router-dom:
|
||||||
|
specifier: 7.18.1
|
||||||
|
version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
zod:
|
zod:
|
||||||
specifier: 4.4.3
|
specifier: 4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -1260,6 +1263,23 @@ packages:
|
|||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
react-router-dom@7.18.1:
|
||||||
|
resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
|
||||||
|
react-router@7.18.1:
|
||||||
|
resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react@19.2.8:
|
react@19.2.8:
|
||||||
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1312,6 +1332,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2:
|
||||||
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
set-cookie-parser@3.1.2:
|
set-cookie-parser@3.1.2:
|
||||||
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
|
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
|
||||||
|
|
||||||
@@ -2604,6 +2627,20 @@ snapshots:
|
|||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
|
react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
react-dom: 19.2.8(react@19.2.8)
|
||||||
|
react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
|
||||||
|
react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||||
|
dependencies:
|
||||||
|
cookie: 1.1.1
|
||||||
|
react: 19.2.8
|
||||||
|
set-cookie-parser: 2.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.8(react@19.2.8)
|
||||||
|
|
||||||
react@19.2.8: {}
|
react@19.2.8: {}
|
||||||
|
|
||||||
rechoir@0.8.0:
|
rechoir@0.8.0:
|
||||||
@@ -2663,6 +2700,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
set-cookie-parser@3.1.2: {}
|
set-cookie-parser@3.1.2: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
|
|||||||
+6
-11
@@ -1,18 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js";
|
||||||
|
import { AppRouter } from "../presentation/routes/app-router.jsx";
|
||||||
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
|
import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js";
|
||||||
|
|
||||||
/** @param {{ environment: string }} props */
|
|
||||||
function BootstrapShell({ environment }) {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<h1>Clean Architecture Frontend</h1>
|
|
||||||
<p>{environment} 런타임 계약이 검증되었습니다.</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {{ supportReference: string }} props */
|
/** @param {{ supportReference: string }} props */
|
||||||
function BootErrorShell({ supportReference }) {
|
function BootErrorShell({ supportReference }) {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +28,10 @@ async function boot() {
|
|||||||
const runtime = await loadRuntimeConfig();
|
const runtime = await loadRuntimeConfig();
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BootstrapShell environment={runtime.config.APP_ENV} />
|
<AppRouter
|
||||||
|
authSession={createAnonymousSessionAdapter()}
|
||||||
|
basename={runtime.build.routerBasePath}
|
||||||
|
/>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* routeId: string,
|
||||||
|
* path: string,
|
||||||
|
* paramsSchema: string | null,
|
||||||
|
* searchSchema: string | null,
|
||||||
|
* access: "public" | "session-required" | "integration-defined",
|
||||||
|
* loadingSurface: string,
|
||||||
|
* errorSurface: string,
|
||||||
|
* chunkId: string
|
||||||
|
* }} RouteDefinition
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @param {RouteDefinition} definition */
|
||||||
|
const route = (definition) => Object.freeze(definition);
|
||||||
|
|
||||||
|
export const ROUTE_REGISTRY = Object.freeze({
|
||||||
|
APP_HOME: route({
|
||||||
|
routeId: "APP_HOME",
|
||||||
|
path: "/",
|
||||||
|
paramsSchema: null,
|
||||||
|
searchSchema: null,
|
||||||
|
access: "public",
|
||||||
|
loadingSurface: "app-shell",
|
||||||
|
errorSurface: "route-boundary",
|
||||||
|
chunkId: "route-home",
|
||||||
|
}),
|
||||||
|
SAMPLE_RESOURCE_LIST: route({
|
||||||
|
routeId: "SAMPLE_RESOURCE_LIST",
|
||||||
|
path: "/sample/resources",
|
||||||
|
paramsSchema: null,
|
||||||
|
searchSchema: "SampleResourceListQuery",
|
||||||
|
access: "integration-defined",
|
||||||
|
loadingSurface: "sample-resource-list",
|
||||||
|
errorSurface: "feature-boundary",
|
||||||
|
chunkId: "route-sample-resources",
|
||||||
|
}),
|
||||||
|
NOT_FOUND: route({
|
||||||
|
routeId: "NOT_FOUND",
|
||||||
|
path: "*",
|
||||||
|
paramsSchema: null,
|
||||||
|
searchSchema: null,
|
||||||
|
access: "public",
|
||||||
|
loadingSurface: "none",
|
||||||
|
errorSurface: "not-found",
|
||||||
|
chunkId: "route-not-found",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @param {string} routeId */
|
||||||
|
export function getRoute(routeId) {
|
||||||
|
const registry = /** @type {Record<string, Readonly<RouteDefinition>>} */ (
|
||||||
|
ROUTE_REGISTRY
|
||||||
|
);
|
||||||
|
const selected = registry[routeId];
|
||||||
|
if (!selected) throw new Error(`Unregistered route: ${routeId}`);
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} routeId */
|
||||||
|
export function routePath(routeId) {
|
||||||
|
return getRoute(routeId).path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
BrowserRouter,
|
||||||
|
Link,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
|
import { routePath } from "../../contracts/routes.js";
|
||||||
|
import { decideRouteAccess } from "./navigation-policy.js";
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Clean Architecture Frontend</h1>
|
||||||
|
<p>런타임 계약이 검증되었습니다.</p>
|
||||||
|
<Link to={routePath("SAMPLE_RESOURCE_LIST")}>샘플 리소스</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SamplePlaceholder() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>샘플 리소스</h1>
|
||||||
|
<p>계약 fixture를 준비하고 있습니다.</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>페이지를 찾을 수 없습니다.</h1>
|
||||||
|
<Link to={routePath("APP_HOME")}>홈으로 이동</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
function GuardedSampleRoute({ authSession }) {
|
||||||
|
const decision = decideRouteAccess(
|
||||||
|
"SAMPLE_RESOURCE_LIST",
|
||||||
|
authSession.getState(),
|
||||||
|
);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>세션이 필요합니다.</h1>
|
||||||
|
<button type="button">로그인</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <SamplePlaceholder />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* authSession: import("../../application/ports/auth-session-port.js").AuthSessionPort,
|
||||||
|
* basename?: string
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export function AppRouter({ authSession, basename = "/" }) {
|
||||||
|
return (
|
||||||
|
<BrowserRouter basename={basename}>
|
||||||
|
<Routes>
|
||||||
|
<Route path={routePath("APP_HOME")} element={<HomePage />} />
|
||||||
|
<Route
|
||||||
|
path={routePath("SAMPLE_RESOURCE_LIST")}
|
||||||
|
element={<GuardedSampleRoute authSession={authSession} />}
|
||||||
|
/>
|
||||||
|
<Route path={routePath("NOT_FOUND")} element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getRoute } from "../../contracts/routes.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} routeId
|
||||||
|
* @param {import("../../application/ports/auth-session-port.js").SessionState} sessionState
|
||||||
|
*/
|
||||||
|
export function decideRouteAccess(routeId, sessionState) {
|
||||||
|
const route = getRoute(routeId);
|
||||||
|
if (route.access === "public") return { allowed: true, action: "none" };
|
||||||
|
if (sessionState === "authenticated") {
|
||||||
|
return { allowed: true, action: "none" };
|
||||||
|
}
|
||||||
|
if (sessionState === "recovery-pending") {
|
||||||
|
return { allowed: false, action: "wait-for-session" };
|
||||||
|
}
|
||||||
|
return { allowed: false, action: "show-sign-in" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRedirectLoopGuard() {
|
||||||
|
const visitedPairs = new Set();
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
/**
|
||||||
|
* @param {string} source
|
||||||
|
* @param {string} target
|
||||||
|
*/
|
||||||
|
allow(source, target) {
|
||||||
|
const pair = `${source}->${target}`;
|
||||||
|
if (source === target || visitedPairs.has(pair)) return false;
|
||||||
|
visitedPairs.add(pair);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
visitedPairs.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||||
|
import { AppRouter } from "../../src/presentation/routes/app-router.jsx";
|
||||||
|
|
||||||
|
describe("application router", () => {
|
||||||
|
it("renders not-found without making an API request", () => {
|
||||||
|
window.history.pushState({}, "", "/missing");
|
||||||
|
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows session-required UX without claiming authorization", () => {
|
||||||
|
window.history.pushState({}, "", "/sample/resources");
|
||||||
|
render(<AppRouter authSession={createAnonymousSessionAdapter()} />);
|
||||||
|
expect(screen.getByRole("heading", { name: "세션이 필요합니다." })).toBeVisible();
|
||||||
|
expect(screen.getByRole("button", { name: "로그인" })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRedirectLoopGuard,
|
||||||
|
decideRouteAccess,
|
||||||
|
} from "../../src/presentation/routes/navigation-policy.js";
|
||||||
|
import { ROUTE_REGISTRY } from "../../src/contracts/routes.js";
|
||||||
|
|
||||||
|
describe("route registry", () => {
|
||||||
|
it("matches the stable registry snapshot", () => {
|
||||||
|
expect(ROUTE_REGISTRY).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"APP_HOME": {
|
||||||
|
"access": "public",
|
||||||
|
"chunkId": "route-home",
|
||||||
|
"errorSurface": "route-boundary",
|
||||||
|
"loadingSurface": "app-shell",
|
||||||
|
"paramsSchema": null,
|
||||||
|
"path": "/",
|
||||||
|
"routeId": "APP_HOME",
|
||||||
|
"searchSchema": null,
|
||||||
|
},
|
||||||
|
"NOT_FOUND": {
|
||||||
|
"access": "public",
|
||||||
|
"chunkId": "route-not-found",
|
||||||
|
"errorSurface": "not-found",
|
||||||
|
"loadingSurface": "none",
|
||||||
|
"paramsSchema": null,
|
||||||
|
"path": "*",
|
||||||
|
"routeId": "NOT_FOUND",
|
||||||
|
"searchSchema": null,
|
||||||
|
},
|
||||||
|
"SAMPLE_RESOURCE_LIST": {
|
||||||
|
"access": "integration-defined",
|
||||||
|
"chunkId": "route-sample-resources",
|
||||||
|
"errorSurface": "feature-boundary",
|
||||||
|
"loadingSurface": "sample-resource-list",
|
||||||
|
"paramsSchema": null,
|
||||||
|
"path": "/sample/resources",
|
||||||
|
"routeId": "SAMPLE_RESOURCE_LIST",
|
||||||
|
"searchSchema": "SampleResourceListQuery",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats client access as a UX hint, not authorization", () => {
|
||||||
|
expect(decideRouteAccess("APP_HOME", "unauthenticated")).toEqual({
|
||||||
|
allowed: true,
|
||||||
|
action: "none",
|
||||||
|
});
|
||||||
|
expect(decideRouteAccess("SAMPLE_RESOURCE_LIST", "unauthenticated")).toEqual({
|
||||||
|
allowed: false,
|
||||||
|
action: "show-sign-in",
|
||||||
|
});
|
||||||
|
expect(decideRouteAccess("SAMPLE_RESOURCE_LIST", "authenticated")).toEqual({
|
||||||
|
allowed: true,
|
||||||
|
action: "none",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows at most one automatic redirect per source-target pair", () => {
|
||||||
|
const guard = createRedirectLoopGuard();
|
||||||
|
expect(guard.allow("/private", "/signin")).toBe(true);
|
||||||
|
expect(guard.allow("/private", "/signin")).toBe(false);
|
||||||
|
expect(guard.allow("/signin", "/signin")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user