첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
commit
6a8dbeb2e9
1211 files changed
+312864
No files matched your search
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { userManager } from "../../lib/auth";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// 팝업으로 열린 경우 signinPopupCallback 처리
|
||||
if (window.opener) {
|
||||
userManager.signinPopupCallback().catch((error) => {
|
||||
console.error("Popup callback failed:", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
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
|
||||
: "/clients";
|
||||
navigate(returnTo, { replace: true });
|
||||
} else if (auth.error) {
|
||||
console.error("Auth Error:", auth.error);
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
|
||||
|
||||
return <div>Loading Auth...</div>;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { userManager } from "../../lib/auth";
|
||||
import { findPersistedOidcUser } from "../../lib/oidcStorage";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
const [hasStoredUser, setHasStoredUser] = useState<boolean | null>(() =>
|
||||
findPersistedOidcUser() ? true : null,
|
||||
);
|
||||
const isDevelopmentMode = import.meta.env.MODE === "development";
|
||||
const isTestMode =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true || navigator.webdriver === true;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (isDevelopmentMode || isTestMode) {
|
||||
setHasStoredUser(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const persistedUser = findPersistedOidcUser();
|
||||
if (persistedUser) {
|
||||
setHasStoredUser(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void userManager
|
||||
.getUser()
|
||||
.then((user) => {
|
||||
if (!cancelled) {
|
||||
setHasStoredUser(Boolean(user && !user.expired));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setHasStoredUser(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isTestMode]);
|
||||
|
||||
if (isDevelopmentMode || isTestMode) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
if (auth.isLoading || auth.activeNavigator || hasStoredUser === null) {
|
||||
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-red-500">
|
||||
<h2 className="text-xl font-bold">Authentication Error</h2>
|
||||
<p>{auth.error.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void auth.signinRedirect()}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
>
|
||||
Start Login Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated && !hasStoredUser) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
|
||||
|
||||
const flows = [
|
||||
{
|
||||
title: "Admin login",
|
||||
description:
|
||||
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
|
||||
pill: "15m TTL",
|
||||
},
|
||||
{
|
||||
title: "Tenant pick",
|
||||
description:
|
||||
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
|
||||
pill: "Header-ready",
|
||||
},
|
||||
{
|
||||
title: "Device approval",
|
||||
description:
|
||||
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
|
||||
pill: "App session",
|
||||
},
|
||||
];
|
||||
|
||||
function AuthPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
|
||||
Admin auth
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Build the admin-only login flow first, keeping app login separate.
|
||||
Respect the “fallback only when user chooses” rule for SMS/email
|
||||
vs app approval.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
|
||||
IDP session placeholder
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Connect auth layer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.title}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
|
||||
<span>{flow.pill}</span>
|
||||
<Fingerprint size={14} />
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{flow.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<Smartphone size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
App-based approvals
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
App session as MFA replacement
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
If the admin keeps the mobile app signed in and opts in, use
|
||||
push/deeplink approval instead of OTP. Otherwise fall back to
|
||||
SMS/email based on user choice.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
|
||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
||||
<ArrowRight size={16} />
|
||||
<span className="text-xs uppercase tracking-[0.18em]">
|
||||
TTL discipline
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-2 text-xl font-semibold">
|
||||
Keep admin sessions short
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
|
||||
with step-up MFA when critical actions (rotate secret, export logs)
|
||||
happen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPage;
|
||||
@@ -0,0 +1,177 @@
|
||||
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";
|
||||
|
||||
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") || "/clients";
|
||||
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(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
navigate(returnTo, { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, navigate, returnTo]);
|
||||
|
||||
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: "/clients",
|
||||
},
|
||||
});
|
||||
} 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]">
|
||||
Developer Control Plane
|
||||
</p>
|
||||
</div>
|
||||
</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">
|
||||
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
|
||||
<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;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchMe } from "./authApi";
|
||||
|
||||
const getMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/apiClient", () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => getMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("fetchMe", () => {
|
||||
it("returns the response payload from the API client", async () => {
|
||||
getMock.mockResolvedValueOnce({ data: { id: "user-1", name: "Dev" } });
|
||||
|
||||
await expect(fetchMe()).resolves.toEqual({ id: "user-1", name: "Dev" });
|
||||
expect(getMock).toHaveBeenCalledWith("/user/me");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import apiClient from "../../lib/apiClient";
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
role: string;
|
||||
companyCode?: string;
|
||||
tenantId?: string;
|
||||
tenant?: Tenant;
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<UserProfile>("/user/me");
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AuthCallbackPage from "./AuthCallbackPage";
|
||||
import AuthGuard from "./AuthGuard";
|
||||
import AuthPage from "./AuthPage";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: undefined as
|
||||
| {
|
||||
state?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
signinRedirect: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/auth", () => ({
|
||||
userManager: {
|
||||
getUser: vi.fn(async () => undefined),
|
||||
signinPopupCallback: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
authState.isAuthenticated = false;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = null;
|
||||
authState.user = undefined;
|
||||
authState.signinRedirect.mockReset();
|
||||
authState.signinRedirect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function renderWithRouter(
|
||||
element: React.ReactElement,
|
||||
{
|
||||
entry = "/",
|
||||
path = "*",
|
||||
}: {
|
||||
entry?: string;
|
||||
path?: string;
|
||||
} = {},
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path={path} element={element}>
|
||||
<Route index element={<div>Protected outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login route</div>} />
|
||||
<Route path="/clients" element={<div>Clients route</div>} />
|
||||
<Route path="/profile" element={<div>Profile route</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("devfront auth pages", () => {
|
||||
it("renders the static auth planning page", async () => {
|
||||
const container = await renderWithRouter(<AuthPage />);
|
||||
|
||||
expect(container.textContent).toContain("Admin auth guardrails");
|
||||
expect(container.textContent).toContain("Device approval");
|
||||
});
|
||||
|
||||
it("renders login page and starts SSO redirect from the action button", async () => {
|
||||
const container = await renderWithRouter(<LoginPage />, {
|
||||
entry: "/login?returnTo=/profile",
|
||||
path: "/login",
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("개발자 포털 로그인");
|
||||
|
||||
const loginButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("SSO 계정으로 로그인"),
|
||||
);
|
||||
await act(async () => {
|
||||
(loginButton as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(authState.signinRedirect).toHaveBeenCalledWith({
|
||||
state: { returnTo: "/clients" },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows AuthGuard loading, error, redirect, and protected outlet states", async () => {
|
||||
authState.isLoading = true;
|
||||
const loading = await renderWithRouter(<AuthGuard />);
|
||||
expect(loading.textContent).toContain("Loading...");
|
||||
|
||||
authState.isLoading = false;
|
||||
authState.error = new Error("OIDC failed");
|
||||
const error = await renderWithRouter(<AuthGuard />);
|
||||
expect(error.textContent).toContain("Authentication Error");
|
||||
|
||||
const retryButton = error.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
retryButton.click();
|
||||
});
|
||||
expect(authState.signinRedirect).toHaveBeenCalled();
|
||||
|
||||
authState.error = null;
|
||||
const redirected = await renderWithRouter(<AuthGuard />);
|
||||
expect(redirected.textContent).toContain("Login route");
|
||||
|
||||
authState.isAuthenticated = true;
|
||||
const protectedPage = await renderWithRouter(<AuthGuard />);
|
||||
expect(protectedPage.textContent).toContain("Protected outlet");
|
||||
});
|
||||
|
||||
it("navigates from callback by auth result and stored return target", async () => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = { state: { returnTo: "/profile" } };
|
||||
|
||||
const authenticated = await renderWithRouter(<AuthCallbackPage />, {
|
||||
entry: "/auth/callback",
|
||||
path: "/auth/callback",
|
||||
});
|
||||
expect(authenticated.textContent).toContain("Profile route");
|
||||
|
||||
authState.isAuthenticated = false;
|
||||
authState.error = new Error("callback failed");
|
||||
const failed = await renderWithRouter(<AuthCallbackPage />, {
|
||||
entry: "/auth/callback",
|
||||
path: "/auth/callback",
|
||||
});
|
||||
expect(failed.textContent).toContain("Login route");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user