첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
56
baron-sso/adminfront/src/features/auth/AuthCallbackPage.tsx
Normal file
56
baron-sso/adminfront/src/features/auth/AuthCallbackPage.tsx
Normal 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;
|
||||
56
baron-sso/adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
56
baron-sso/adminfront/src/features/auth/AuthGuard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
59
baron-sso/adminfront/src/features/auth/AuthGuard.tsx
Normal file
59
baron-sso/adminfront/src/features/auth/AuthGuard.tsx
Normal 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 />;
|
||||
}
|
||||
38
baron-sso/adminfront/src/features/auth/AuthPage.test.tsx
Normal file
38
baron-sso/adminfront/src/features/auth/AuthPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
24
baron-sso/adminfront/src/features/auth/AuthPage.tsx
Normal file
24
baron-sso/adminfront/src/features/auth/AuthPage.tsx
Normal 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;
|
||||
76
baron-sso/adminfront/src/features/auth/LoginPage.test.tsx
Normal file
76
baron-sso/adminfront/src/features/auth/LoginPage.test.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
206
baron-sso/adminfront/src/features/auth/LoginPage.tsx
Normal file
206
baron-sso/adminfront/src/features/auth/LoginPage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user