첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
import { ShieldHalf } from "lucide-react";
import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { debugLog } from "../../lib/debugLog";
function AuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
debugLog("[AuthCallbackPage] State:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
error: auth.error,
});
if (auth.isAuthenticated) {
// Save token to localStorage for existing API clients that might still use it
const user = auth.user;
if (user?.access_token) {
window.localStorage.setItem("admin_session", user.access_token);
}
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/";
console.info(
"[AuthCallbackPage] Auth successful, navigating to",
returnTo,
);
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("[AuthCallbackPage] Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate, auth.user, auth.isLoading]);
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-lg animate-pulse">
<ShieldHalf size={32} />
</div>
<div className="text-lg font-semibold"> ...</div>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
</div>
);
}
export default AuthCallbackPage;

View File

@@ -0,0 +1,56 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AuthGuard from "./AuthGuard";
const authState = {
activeNavigator: undefined,
error: undefined as Error | undefined,
isAuthenticated: false,
isLoading: false,
removeUser: vi.fn(async () => undefined),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
function renderAuthGuard(initialEntry = "/users") {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<AuthGuard />}>
<Route path="users" element={<div>Users outlet</div>} />
</Route>
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>
</MemoryRouter>,
);
}
describe("AuthGuard", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = false;
authState.activeNavigator = undefined;
authState.error = undefined;
authState.isAuthenticated = false;
authState.isLoading = false;
authState.removeUser.mockClear();
window.localStorage.clear();
});
it("clears stale auth state and returns to login when OIDC reports an error", async () => {
window.localStorage.setItem("admin_session", "stale-token");
authState.error = new Error("stale session");
renderAuthGuard();
await waitFor(() => {
expect(authState.removeUser).toHaveBeenCalled();
});
await screen.findByText("Login outlet");
expect(window.localStorage.getItem("admin_session")).toBeNull();
});
});

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import { clearStoredAdminAuthSession } from "../../lib/auth";
export default function AuthGuard() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const handledAuthErrorRef = useRef(false);
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
useEffect(() => {
if (!auth.error || handledAuthErrorRef.current || isTest) {
return;
}
handledAuthErrorRef.current = true;
clearStoredAdminAuthSession();
void Promise.resolve(
auth.removeUser ? auth.removeUser() : undefined,
).finally(() => {
navigate("/login", { replace: true });
});
}, [auth, auth.error, isTest, navigate]);
if (isTest) {
return <Outlet />;
}
if (auth.isLoading || auth.activeNavigator) {
return <div>Loading...</div>;
}
if (auth.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<div className="mb-4 text-destructive">
<h2 className="text-xl font-bold"> </h2>
<p>{auth.error.message}</p>
</div>
</div>
);
}
if (!auth.isAuthenticated) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
return (
<Navigate
to={`/login?returnTo=${encodeURIComponent(returnTo)}`}
replace
/>
);
}
return <Outlet />;
}

View File

@@ -0,0 +1,38 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import AuthPage from "./AuthPage";
vi.mock("../../lib/i18n", () => createI18nMock());
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<AuthPage />
</QueryClientProvider>,
);
}
describe("AuthPage", () => {
beforeEach(() => {
window.localStorage.setItem("locale", "en");
});
it("renders localized auth guard labels in English", () => {
renderPage();
expect(screen.getByText("Auth Guard")).toBeInTheDocument();
expect(screen.getByText("ReBAC permission checker")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Check permission" }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,24 @@
import { ShieldHalf } from "lucide-react";
import { PageHeader } from "../../../../common/core/components/page";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
function AuthPage() {
return (
<div className="space-y-6">
<PageHeader
titleAs="h2"
icon={<ShieldHalf size={20} />}
title={t("ui.admin.auth_guard.title", "Auth Guard")}
description={t(
"ui.admin.auth_guard.subtitle",
"Verify admin privileges and ReBAC relationships against the policy engine.",
)}
/>
<PermissionChecker />
</div>
);
}
export default AuthPage;

View File

@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "./LoginPage";
const mockSigninRedirect = vi.fn();
const mockUseAuth = vi.fn();
vi.mock("react-oidc-context", () => ({
useAuth: () => mockUseAuth(),
}));
function renderLoginPage(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<LoginPage />
</MemoryRouter>,
);
}
describe("LoginPage", () => {
beforeEach(() => {
Object.defineProperty(window, "crypto", {
configurable: true,
value: {},
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: false,
});
mockSigninRedirect.mockReset();
mockUseAuth.mockReturnValue({
activeNavigator: undefined,
error: undefined,
isAuthenticated: false,
isLoading: false,
signinRedirect: mockSigninRedirect,
});
});
it("shows an actionable error instead of starting PKCE when WebCrypto is unavailable", async () => {
renderLoginPage("/login?returnTo=%2F");
await userEvent.click(
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
);
expect(mockSigninRedirect).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
/SSO 로그인을 시작할 수 없습니다/,
);
});
it("preserves the returnTo query when starting SSO manually", async () => {
Object.defineProperty(window, "crypto", {
configurable: true,
value: { subtle: {} },
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: true,
});
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
await userEvent.click(
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
);
expect(mockSigninRedirect).toHaveBeenCalledWith({
state: {
returnTo: "/users?page=2",
},
});
});
});

View File

@@ -0,0 +1,206 @@
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
import { debugLog } from "../../lib/debugLog";
const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
function isPkceSetupFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
}
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const [loginError, setLoginError] = useState<string | null>(null);
const returnTo = searchParams.get("returnTo") || "/";
const shouldAutoLogin = searchParams.get("auto") === "1";
const authErrorMessage = useMemo(() => {
const message = auth.error?.message;
if (!message) {
return null;
}
if (message.includes("Crypto.subtle")) {
return insecurePkceMessage;
}
return message;
}, [auth.error?.message]);
const visibleLoginError = loginError || authErrorMessage;
useEffect(() => {
debugLog("[LoginPage] Auth state check:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
returnTo,
});
if (auth.isAuthenticated) {
console.info(
"[LoginPage] User is authenticated, redirecting to",
returnTo,
);
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo, auth.isLoading]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
autoStartedRef.current = true;
void auth
.signinRedirect({
state: {
returnTo,
},
})
.catch((error) => {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Auto login redirect failed", error);
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = async () => {
try {
setLoginError(null);
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
await auth.signinRedirect({
state: {
returnTo,
},
});
} catch (error) {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Redirect login failed", error);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
<div className="w-full max-w-md space-y-8">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
<ShieldHalf size={32} />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
Admin Control Plane
</p>
</div>
</div>
{auth.error && (
<div className="rounded-lg bg-destructive/15 p-4 text-sm text-destructive border border-destructive/20 animate-in fade-in slide-in-from-top-1">
<div className="font-bold flex items-center gap-2 mb-1">
<ShieldHalf size={16} />
</div>
<p className="opacity-90">{auth.error.message}</p>
<Button
variant="ghost"
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
onClick={() => {
void handleSSOLogin();
}}
>
</Button>
</div>
)}
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
</CardTitle>
<CardDescription>
Baron (SSO) .
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
>
{auth.isLoading ? (
<>
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...
</>
) : (
<>
<ShieldHalf size={22} />
SSO
<ExternalLink size={16} className="opacity-50" />
</>
)}
</Button>
{visibleLoginError ? (
<div
role="alert"
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{visibleLoginError}</span>
</div>
) : null}
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
15 .
<br />
.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
<br />
.
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -0,0 +1,188 @@
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, XCircle } from "lucide-react";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import apiClient from "../../../lib/apiClient";
import { t } from "../../../lib/i18n";
type CheckPermissionResponse = {
allowed: boolean;
query: {
namespace: string;
object: string;
relation: string;
subject: string;
};
};
function PermissionChecker() {
const [namespace, setNamespace] = useState("Tenant");
const [object, setObject] = useState("");
const [relation, setRelation] = useState("manage");
const [subject, setSubject] = useState("");
const checkMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.get<CheckPermissionResponse>(
"/v1/admin/debug/check-permission",
{
params: { namespace, object, relation, subject },
},
);
return data;
},
});
const result = checkMutation.data;
return (
<Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.auth_guard.checker.description",
"Check in real time whether a subject has access to a resource through Ory Keto.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
</Label>
<select
id="permission-checker-namespace"
name="permission-checker-namespace"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="Tenant">
{t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
</option>
<option value="TenantGroup">
{t(
"ui.admin.auth_guard.checker.namespace.tenant_group",
"TenantGroup",
)}
</option>
<option value="RelyingParty">
{t(
"ui.admin.auth_guard.checker.namespace.relying_party",
"RelyingParty",
)}
</option>
<option value="System">
{t("ui.admin.auth_guard.checker.namespace.system", "System")}
</option>
</select>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.relation", "Relation")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.relation_placeholder",
"view, manage, admins...",
)}
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.object_id", "Object ID")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.object_id_placeholder",
"Tenant UUID, etc.",
)}
value={object}
onChange={(e) => setObject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.subject", "Subject (User:ID)")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.subject_placeholder",
"User:uuid or Namespace:ID#Relation",
)}
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
</div>
<div className="flex justify-center">
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full px-12 md:w-auto"
>
{checkMutation.isPending
? t("ui.admin.auth_guard.checker.checking", "Checking...")
: t("ui.admin.auth_guard.checker.check", "Check permission")}
</Button>
</div>
{checkMutation.isSuccess && result && (
<div
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
result.allowed
? "border-green-500/50 bg-green-500/10 text-green-600"
: "border-destructive/50 bg-destructive/10 text-destructive"
}`}
>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}
</div>
<p className="text-center text-sm opacity-80">
{t(
"ui.admin.auth_guard.checker.allowed_description",
"The subject has access to the requested resource, including inherited permissions.",
)}
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.denied", "Access DENIED")}
</div>
<p className="text-center text-sm opacity-80">
{t(
"ui.admin.auth_guard.checker.denied_description",
"The subject does not have access to the requested resource.",
)}
</p>
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default PermissionChecker;