1
0
forked from baron/baron-sso

ory용 MCP 제작, devfront/adminfront 백엔드 연결

This commit is contained in:
Lectom C Han
2026-01-28 10:57:22 +09:00
parent 1aaa772907
commit 93cab064fc
75 changed files with 7327 additions and 454 deletions

View File

@@ -0,0 +1,11 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
});

View File

@@ -0,0 +1,28 @@
import { Navigate, createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
import ClientsPage from "../features/clients/ClientsPage";
export const router = createBrowserRouter(
[
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="/clients" replace /> },
{ path: "clients", element: <ClientsPage /> },
{ path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
],
},
],
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
{
future: {
v7_startTransition: true,
},
} as unknown as Parameters<typeof createBrowserRouter>[1],
);

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,112 @@
import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
const navItems = [{ label: "Clients", to: "/clients", icon: ShieldHalf }];
function AppLayout() {
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Baron
</p>
<h1 className="text-lg font-semibold">Developer Console</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
Scoped to /dev
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
Env: dev
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{label}</span>
</NavLink>
))}
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p> .</p>
<p> .</p>
</div>
</aside>
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
Dev Plane
</p>
<span className="text-lg font-semibold">
Manage your applications
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
aria-label="테마 전환"
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
{theme === "light" ? "Light" : "Dark"}
</button>
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
</div>
</div>
);
}
export default AppLayout;

View File

@@ -0,0 +1,47 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from "../../lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,38 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "text-foreground",
muted: "border-border bg-secondary/60 text-muted-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,56 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,72 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
className,
)}
{...props}
/>
);
}
function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
);
}
function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
}
function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
}
function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
}
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };

View File

@@ -0,0 +1,44 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && "h-2.5 border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,16 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Separator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("shrink-0 bg-border", "h-px w-full", className)}
{...props}
/>
));
Separator.displayName = "Separator";
export { Separator };

View File

@@ -0,0 +1,26 @@
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "../../lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,113 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-muted/50 font-medium text-foreground", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-6 align-middle text-sm", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -0,0 +1,143 @@
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
const auditFilters = [
"Actor role = admin",
"Action = client.rotate_secret",
"Tenant = selected header",
];
const auditRows = [
{
action: "client.create",
tenant: "TENANT-12",
actor: "ops.jane@baron",
result: "ok",
ts: "2026-01-26 15:21 KST",
},
{
action: "client.rotate_secret",
tenant: "TENANT-12",
actor: "ops.jane@baron",
result: "ok",
ts: "2026-01-26 15:22 KST",
},
{
action: "audit.export",
tenant: "TENANT-07",
actor: "auditor.lee@baron",
result: "rate_limited",
ts: "2026-01-26 15:30 KST",
},
];
function AuditLogsPage() {
return (
<div className="space-y-8">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Audit stream
</p>
<h2 className="text-2xl font-semibold">
Observe admin actions per tenant
</h2>
<p className="text-sm text-[var(--color-muted)]">
ClickHouse-backed feed. Filter by tenant, actor, action, and
rate-limit status. Enforce admin-only access under /admin.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]"
>
<Filter size={14} />
Saved filters
</button>
<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"
>
<ListChecks size={14} />
Export CSV
</button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1.1fr,0.9fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-[var(--color-muted)]">
<Search size={14} />
<span className="text-sm">
Try: tenant:TENANT-12 action:client.*
</span>
</div>
<div className="mt-4 space-y-3">
{auditFilters.map((filter) => (
<span
key={filter}
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-muted)]"
>
<Terminal size={12} />
{filter}
</span>
))}
</div>
<div className="mt-5 divide-y divide-[var(--color-border)]">
{auditRows.map((row) => (
<div
key={`${row.action}-${row.ts}`}
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
>
<div className="font-semibold">{row.action}</div>
<div className="text-[var(--color-muted)]">{row.tenant}</div>
<div className="text-[var(--color-muted)]">{row.actor}</div>
<div className="inline-flex items-center gap-2">
<span
className={`rounded-full px-2 py-1 text-xs ${
row.result === "ok"
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
}`}
>
{row.result}
</span>
<span className="text-[var(--color-muted)]">{row.ts}</span>
</div>
</div>
))}
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
Guard rails
</p>
<h3 className="mt-1 text-lg font-semibold">Tenant admin only</h3>
<p className="text-sm text-[var(--color-muted)]">
Enforce Tenant Admin middleware and admin session TTL before
surfacing any audit feed. Super Admin role can bypass tenant
filter when needed.
</p>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
Export rules
</p>
<h3 className="mt-1 text-lg font-semibold">
Rate-limit sensitive exports
</h3>
<p className="text-sm text-[var(--color-muted)]">
Keep export endpoints behind admin-only routes with ClickHouse
query limits. Log download attempts with IP, role, and tenant
scope.
</p>
</div>
</div>
</div>
</div>
);
}
export default AuditLogsPage;

View File

@@ -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;

View File

@@ -0,0 +1,259 @@
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Filter,
Search,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
const rows = [
{
initials: "JD",
name: "John Doe",
email: "john.doe@example.com",
scopes: ["openid", "profile", "email", "offline_access"],
lastAuth: "Oct 24, 2023 14:22",
},
{
initials: "AS",
name: "Alice Smith",
email: "alice.smith@devmail.com",
scopes: ["openid", "profile"],
lastAuth: "Oct 23, 2023 09:15",
},
{
initials: "RJ",
name: "Robert Johnson",
email: "r.johnson@corporate.org",
scopes: ["openid", "profile", "groups"],
lastAuth: "Oct 21, 2023 18:45",
},
{
initials: "ML",
name: "Maria Lopez",
email: "maria.l@provider.net",
scopes: ["openid", "email"],
lastAuth: "Oct 20, 2023 11:30",
},
];
function ClientConsentsPage() {
return (
<div className="space-y-8">
<header className="space-y-4">
<div className="flex flex-wrap justify-between gap-4">
<div className="space-y-2">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
Home
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
Clients
</Link>
<span>/</span>
<span>OIDC Relying Party</span>
<span>/</span>
<span className="text-foreground font-semibold">
User Consent Grants
</span>
</nav>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to="/clients/cli_481...8k2">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<p className="text-3xl font-black leading-tight">
User Consent Grants
</p>
<p className="text-muted-foreground">
OIDC Relying Party ·.
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="success">Active</Badge>
</div>
</div>
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
<Link
to="/clients/cli_481...8k2"
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Overview
</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
Consent &amp; Users
</span>
<Link
to="/clients/cli_481...8k2/settings"
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Settings
</Link>
</div>
</header>
<Card className="glass-panel">
<CardContent className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4 flex-1">
<div className="relative w-full max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
placeholder="사용자 ID, 이름, 이메일로 검색"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Status:
</span>
<select className="h-10 rounded-lg border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30">
<option>All Statuses</option>
<option selected>Active</option>
<option>Revoked</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" className="gap-1 text-muted-foreground">
<Filter className="h-4 w-4" />
Advanced Filters
</Button>
<Button className="shadow-sm shadow-primary/30">Export CSV</Button>
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Status</TableHead>
<TableHead>Granted Scopes</TableHead>
<TableHead>Last Authenticated</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.email}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{row.initials}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">{row.name}</span>
<span className="text-xs text-muted-foreground">
{row.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="success">Active</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.scopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="border bg-muted/40 text-foreground"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.lastAuth}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" className="text-destructive">
Revoke
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent className="flex items-center justify-between border-t border-border bg-muted/10 px-6 py-4 text-sm text-muted-foreground">
<p>
Showing <span className="font-semibold text-foreground">1</span> to{" "}
<span className="font-semibold text-foreground">4</span> of{" "}
<span className="font-semibold text-foreground">1,250</span> users
</p>
<div className="flex gap-2">
<Button variant="outline" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="sm">1</Button>
<Button variant="ghost" size="sm">
2
</Button>
<Button variant="ghost" size="sm">
3
</Button>
<Button variant="outline" size="icon">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-3">
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Active Grants
</p>
<CardTitle className="text-2xl font-black">1,250</CardTitle>
</CardHeader>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Total Scopes Issued
</p>
<CardTitle className="text-2xl font-black">4,812</CardTitle>
</CardHeader>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Avg. Scopes per User
</p>
<CardTitle className="text-2xl font-black">3.8</CardTitle>
</CardHeader>
</Card>
</div>
</div>
);
}
export default ClientConsentsPage;

View File

@@ -0,0 +1,194 @@
import { AlertCircle, Copy, Eye, Link2, Shield, Workflow } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import { Separator } from "../../components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "../../components/ui/table";
const endpoints = [
{
label: "Discovery Endpoint",
value: "https://auth.acme-idp.com/.well-known/openid-configuration",
},
{ label: "Issuer URL", value: "https://auth.acme-idp.com/" },
{
label: "Authorization Endpoint",
value: "https://auth.acme-idp.com/oauth2/authorize",
},
{ label: "Token Endpoint", value: "https://auth.acme-idp.com/oauth2/token" },
{
label: "UserInfo Endpoint",
value: "https://auth.acme-idp.com/oauth2/userinfo",
},
];
function ClientDetailsPage() {
return (
<div className="space-y-8">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/clients" className="text-primary hover:underline">
Relying Parties
</Link>
<span>/</span>
<span className="text-foreground"> </span>
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-4xl font-black leading-tight tracking-tight">
Developer Portal App
</h1>
<p className="text-muted-foreground">
OIDC .
</p>
</div>
<Badge variant="success" className="px-3 py-1 text-xs uppercase">
Active
</Badge>
</div>
<div className="flex gap-6 border-b border-border">
<Link
to="/clients/cli_481...8k2"
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
>
Overview
</Link>
<Link
to="/clients/cli_481...8k2/consents"
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
Consent &amp; Users
</Link>
<Link
to="/clients/cli_481...8k2/settings"
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
Settings
</Link>
</div>
</div>
<div className="space-y-4">
<h2 className="text-xl font-bold"> </h2>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Client ID
</p>
<p className="font-mono text-lg">721948305612-oidc-client-prod</p>
</div>
<Button variant="secondary" className="gap-2">
<Copy className="h-4 w-4" />
ID
</Button>
</CardContent>
</Card>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Client Secret
</p>
<p className="font-mono text-lg tracking-widest">
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" className="gap-2">
<Eye className="h-4 w-4" />
</Button>
<Button variant="secondary" className="gap-2">
<Copy className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="gap-2 border-amber-500/50 text-amber-500"
>
<AlertCircle className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">OIDC </h2>
<Badge variant="muted" className="gap-1">
<Link2 className="h-3 w-3" />
</Badge>
</div>
<Card className="glass-panel">
<Table>
<TableBody>
{endpoints.map((endpoint) => (
<TableRow key={endpoint.label} className="border-border/70">
<TableCell className="w-1/3">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
{endpoint.label}
</p>
</TableCell>
<TableCell className="flex items-center justify-between gap-3">
<span className="break-all font-mono text-sm">
{endpoint.value}
</span>
<Button
variant="secondary"
size="icon"
className="h-8 w-8"
aria-label={`${endpoint.label} 복사`}
>
<Copy className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
<div className="glass-panel p-6 opacity-80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<Shield className="h-6 w-6" />
</div>
<div>
<p className="text-lg font-semibold"> </p>
<p className="text-sm text-muted-foreground">
, /
.
</p>
</div>
</div>
<div className="hidden items-center gap-2 md:flex">
<Badge variant="outline" className="gap-1">
<Workflow className="h-4 w-4" />
</Badge>
</div>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">
TTL ,
.
</p>
</div>
</div>
);
}
export default ClientDetailsPage;

View File

@@ -0,0 +1,206 @@
import { Info, Search, Shield, Sparkles, Upload } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
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 { Separator } from "../../components/ui/separator";
import { Textarea } from "../../components/ui/textarea";
import { cn } from "../../lib/utils";
const meta = {
clientId: "client_82910_ax99",
created: "2023-10-12 10:45",
updated: "2 hours ago",
};
function ClientGeneralPage() {
return (
<div className="space-y-8">
<header className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/clients" className="text-primary hover:underline">
Applications
</Link>
<span>/</span>
<span className="text-foreground">Customer Support Portal</span>
</div>
<div>
<p className="text-3xl font-black leading-tight">
Client Details
</p>
<p className="text-muted-foreground">
RP .
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="success" className="px-3 py-1 text-xs uppercase">
Active
</Badge>
</div>
</div>
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
<Link
to="/clients/cli_481...8k2"
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Overview
</Link>
<Link
to="/clients/cli_481...8k2/consents"
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Consent &amp; Users
</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
Settings
</span>
</div>
</header>
<div className="glass-panel p-6">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<CardTitle className="text-xl font-bold">
Application Identity
</CardTitle>
<CardDescription>
, . * .
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex h-10 items-center rounded-lg border border-input bg-secondary/50 px-3 text-sm text-muted-foreground">
<Search className="mr-2 h-4 w-4" />
Search
</div>
<div className="h-10 w-10 overflow-hidden rounded-full border border-border bg-muted/40">
<img
className="h-full w-full object-cover"
alt="앱 로고"
src="https://lh3.googleusercontent.com/aida-public/AB6AXuBFGWfyQ8ZzHXZmha91pG-09N58hcUap10-bU30aIf_CpfOqm8fPIv6j2v_BVGaJMF2gABxv_hnEXUCBvmjZeFpr-c76uC1QQkgMwsdkc2Im0gqS5X1c8sCWLZudDydZo5m7XW-QW1nRSZHYE5XzTqrW2ITgruSa7eC2Oe9RtxeVFCrqcHw3RO3h0WLtyJ8yhkkeZrAyBc4UQtpcL5bhBDSdlUNgw0odf12Mk6oNojf7Rcg4HPnywh6C-mUtJd-UfX7Y3Yv_W704T1a"
/>
</div>
</div>
</div>
<div className="grid gap-8 pt-6 md:grid-cols-2">
<div className="space-y-5">
<div className="space-y-2">
<Label className="flex items-center gap-1 text-sm font-semibold">
<span className="text-destructive">*</span>
</Label>
<Input defaultValue="Customer Support Portal" />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
defaultValue="Internal tool for managing customer support tickets and user data."
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">App Logo URL</Label>
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<Input defaultValue="https://brand.example.com/assets/logo-support.png" />
<p className="text-xs text-muted-foreground">
PNG/SVG URL을 .
</p>
</div>
<div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-border bg-muted/40">
<Upload className="h-5 w-5 text-muted-foreground" />
</div>
</div>
</div>
</div>
</div>
<Card className="glass-panel">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold"> </CardTitle>
<CardDescription>
.
Public을 .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Label className="flex items-center gap-2 text-base font-semibold">
Client Type
<Info className="h-4 w-4 text-muted-foreground" />
</Label>
<div className="grid gap-4 md:grid-cols-2">
<label className="relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 border-primary bg-primary/5 p-4 transition">
<input
className="sr-only"
type="radio"
name="client-type"
defaultChecked
/>
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Shield className="h-4 w-4 text-primary" />
Confidential
</span>
<span className="text-sm text-muted-foreground">
(: Node.js, Java)
.
</span>
<span className="absolute right-4 top-4 text-primary"></span>
</label>
<label className="relative flex cursor-pointer flex-col gap-1 rounded-xl border-2 border-border bg-card p-4 transition hover:border-muted-foreground/40">
<input className="sr-only" type="radio" name="client-type" />
<span className="flex items-center gap-2 text-sm font-bold uppercase text-foreground">
<Sparkles className="h-4 w-4" />
Public
</span>
<span className="text-sm text-muted-foreground">
SPA/ . PKCE를
.
</span>
</label>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3 border-t border-border pt-4">
<Button variant="outline"></Button>
<Button></Button>
</div>
<div className="glass-panel flex flex-wrap gap-x-12 gap-y-4 p-4">
<div className="space-y-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Client ID
</span>
<span className="font-mono text-sm">{meta.clientId}</span>
</div>
<div className="space-y-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Created On
</span>
<span className="text-sm text-muted-foreground">{meta.created}</span>
</div>
<div className="space-y-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Last Updated
</span>
<span className="text-sm text-muted-foreground">{meta.updated}</span>
</div>
</div>
</div>
);
}
export default ClientGeneralPage;

View File

@@ -0,0 +1,366 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
BookOpenText,
Copy,
Laptop,
Plus,
Search,
ServerCog,
ShieldHalf,
} from "lucide-react";
import { Link } from "react-router-dom";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../components/ui/avatar";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Separator } from "../../components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { fetchClients } from "../../lib/devApi";
import { cn } from "../../lib/utils";
function ClientsPage() {
const { data, isLoading, error } = useQuery({
queryKey: ["clients"],
queryFn: fetchClients,
});
const clients = data?.items || [];
const totalClients = clients.length;
// TODO: Add real stats for active sessions and auth failures
const stats = [
{
label: "총 클라이언트",
value: totalClients.toString(),
delta: "Realtime",
tone: "up" as const,
},
{
label: "활성 세션",
value: "-",
delta: "Not impl",
tone: "stable" as const,
},
{
label: "인증 실패 (24h)",
value: "0",
delta: "Stable",
tone: "stable" as const,
},
];
if (isLoading) {
return <div className="p-8 text-center">Loading clients...</div>;
}
if (error) {
const errMsg =
(error as any).response?.data?.error || (error as Error).message;
return (
<div className="p-8 text-center text-red-500">
Error loading clients: {errMsg}
</div>
);
}
return (
<div className="space-y-8">
<Card className="glass-panel">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
RP registry
</p>
<CardTitle className="text-3xl font-black tracking-tight">
Relying Parties
</CardTitle>
<CardDescription>
OIDC , , URI,
.
</CardDescription>
</div>
<div className="hidden items-center gap-2 md:flex">
<Button variant="outline" size="sm">
</Button>
<Button size="sm" className="shadow-lg shadow-primary/30">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-[1.5fr,1fr]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
placeholder="클라이언트 이름/ID로 검색..."
/>
</div>
<div className="flex items-center justify-end gap-2 md:justify-start">
<Badge variant="muted">테넌트: 선택됨</Badge>
<Badge variant="success"> </Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid gap-4 md:grid-cols-3">
{stats.map((item) => (
<Card key={item.label} className="border border-border/60">
<CardHeader className="pb-2">
<CardDescription>{item.label}</CardDescription>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-3xl font-bold">{item.value}</span>
<Badge
variant={
item.tone === "up"
? "success"
: item.tone === "down"
? "warning"
: "muted"
}
className={cn(
"px-2",
item.tone === "down" &&
"bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200",
item.tone === "stable" && "bg-muted/40 text-foreground",
)}
>
{item.delta}
</Badge>
</div>
</CardHeader>
</Card>
))}
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-0">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-semibold">
</CardTitle>
<div className="flex items-center gap-2 md:hidden">
<Button variant="outline" size="sm">
</Button>
<Button size="sm">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Client ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{clients.map((client) => (
<TableRow key={client.id} className="bg-card/40">
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
{client.type === "confidential" ? (
<ServerCog className="h-4 w-4" />
) : (
<ShieldHalf className="h-4 w-4" />
)}
</div>
<div>
<p className="font-semibold">
{client.name || "Untitled"}
</p>
<p className="text-xs text-muted-foreground">
Tenant-scoped
</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{client.id}
</code>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
aria-label="Copy client id"
onClick={() =>
navigator.clipboard.writeText(client.id)
}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</TableCell>
<TableCell>
<Badge
variant={
client.type === "confidential" ? "success" : "muted"
}
>
{client.type === "confidential"
? "기밀(Confidential)"
: "Public"}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-5 w-10 items-center rounded-full p-1",
client.status === "active"
? "bg-primary/40"
: "bg-muted/50",
)}
>
<div
className={cn(
"h-3 w-3 rounded-full bg-background transition",
client.status === "active"
? "translate-x-5"
: "translate-x-0",
)}
/>
</div>
<span
className={cn(
"text-sm font-medium",
client.status === "active"
? "text-emerald-400"
: "text-muted-foreground",
)}
>
{client.status === "active" ? "활성" : "비활성"}
</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{client.createdAt
? new Date(client.createdAt).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${client.id}`}></Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
aria-label="Delete client"
>
<Activity className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 flex items-center justify-between rounded-xl border border-border/60 bg-secondary/60 px-4 py-3 text-sm text-muted-foreground">
<span>
Showing {clients.length} of {totalClients} clients
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled>
Previous
</Button>
<Button variant="outline" size="sm" disabled>
Next
</Button>
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-bold">
Need help with OIDC configuration?
</CardTitle>
<CardDescription>
Developer guides for Confidential/Public clients, redirect URIs,
and auth methods.
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<BookOpenText className="h-6 w-6" />
</div>
<div>
<p className="font-semibold">Docs &amp; Examples</p>
<p className="text-sm text-muted-foreground">
Includes PKCE, client_secret_basic, redirect URI validation
tips.
</p>
</div>
</div>
<Button variant="secondary">View guides</Button>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">Owner</CardTitle>
<CardDescription>Tenant admin on-call</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage
src="https://gitea.hmac.kr/avatars/11ed71f61227be4a9ab6c61885371d92304a4c36a5f71036890625c55daa8c41?size=512"
alt="ops user"
/>
<AvatarFallback>AR</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">AI Admin Bot</p>
<p className="text-xs text-muted-foreground">admin@brsw.kr</p>
</div>
</div>
<Separator className="mx-4 hidden h-10 w-px md:block" />
<div className="hidden flex-col items-end text-sm text-muted-foreground md:flex">
<span>Role: Tenant Admin</span>
<span>Scope: TENANT-12</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export default ClientsPage;

View File

@@ -0,0 +1,215 @@
import {
Activity,
ArrowRight,
BarChart3,
CheckCircle2,
Database,
KeyRound,
ShieldCheck,
Sparkles,
} from "lucide-react";
const guardHighlights = [
{
title: "RP 정책 통제",
body: "Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
metric: "Policy",
},
{
title: "Consent 흐름",
body: "사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
metric: "Consent",
},
{
title: "Hydra Admin",
body: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
metric: "Hydra",
},
];
const stackReadiness = [
"React 19 + Vite 7, strict TS, Router v6 data router.",
"TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
"Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
"Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
"Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
];
const nextSteps = [
"RP 등록/수정/삭제 워크플로우 추가",
"Consent 검색 필터 고도화 및 CSV 내보내기",
"권한 가드 및 감사 로그 연동",
];
function DashboardPage() {
return (
<div className="space-y-10">
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-3 max-w-2xl">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
<Sparkles size={14} />
devfront ready
</div>
<h2 className="text-3xl font-semibold leading-tight">
RP Consent
<span className="text-[var(--color-accent)]"> </span>
.
</h2>
<p className="text-[var(--color-muted)]">
Hydra Admin API와 RP , , Consent
devfront에서 .
</p>
<div className="flex flex-wrap gap-3 text-sm">
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
RP registry synced
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
Consent guard ready
</span>
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
Policy toggle enabled
</span>
</div>
</div>
<div className="grid gap-3 text-sm">
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<ShieldCheck size={16} />
RP dev scope에서만
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<KeyRound size={16} />
Consent
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<Database size={16} />
Hydra Admin
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{guardHighlights.map((item) => (
<div
key={item.title}
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
<div className="relative flex items-center justify-between gap-2">
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
{item.metric}
</div>
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
active
</span>
</div>
<div className="relative mt-3 space-y-2">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
</div>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Stack readiness
</p>
<h3 className="text-xl font-semibold">Devfront baseline</h3>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Setup notes
<ArrowRight size={14} />
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{stackReadiness.map((item) => (
<div
key={item}
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<CheckCircle2
size={16}
className="text-[var(--color-accent)]"
/>
<p className="text-sm">{item}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Next actions
</p>
<h3 className="mt-2 text-xl font-semibold">Ship the RP controls</h3>
<div className="mt-4 space-y-3">
{nextSteps.map((item, idx) => (
<div
key={item}
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
{idx + 1}
</div>
<p className="text-sm text-[var(--color-text)]">{item}</p>
</div>
))}
</div>
</div>
</section>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Ops board
</p>
<h3 className="text-xl font-semibold"> </h3>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
Consent grants
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
RP status
</span>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<BarChart3 size={16} />
RP
</div>
<p className="mt-3 text-2xl font-semibold"> </p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Activity size={16} />
Consent
</div>
<p className="mt-3 text-2xl font-semibold"> </p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Database size={16} />
Hydra
</div>
<p className="mt-3 text-2xl font-semibold"></p>
</div>
</div>
</section>
</div>
);
}
export default DashboardPage;

83
devfront/src/index.css Normal file
View File

@@ -0,0 +1,83 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 25% 6%;
--foreground: 210 35% 96%;
--card: 215 32% 9%;
--card-foreground: 210 35% 96%;
--popover: 215 32% 9%;
--popover-foreground: 210 35% 96%;
--primary: 209 79% 52%;
--primary-foreground: 210 35% 96%;
--secondary: 215 25% 16%;
--secondary-foreground: 210 35% 96%;
--muted: 215 15% 65%;
--muted-foreground: 215 15% 65%;
--accent: 42 95% 57%;
--accent-foreground: 215 25% 10%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 35% 96%;
--border: 215 25% 24%;
--input: 215 25% 24%;
--ring: 209 79% 52%;
--radius: 0.75rem;
}
.light {
--background: 0 0% 98%;
--foreground: 223 25% 12%;
--card: 0 0% 100%;
--card-foreground: 223 25% 12%;
--popover: 0 0% 100%;
--popover-foreground: 223 25% 12%;
--primary: 209 79% 52%;
--primary-foreground: 0 0% 100%;
--secondary: 220 17% 94%;
--secondary-foreground: 223 25% 20%;
--muted: 223 15% 45%;
--muted-foreground: 223 15% 45%;
--accent: 40 96% 62%;
--accent-foreground: 223 25% 12%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 17% 90%;
--input: 220 17% 90%;
--ring: 209 79% 52%;
}
* {
@apply border-border;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%
),
radial-gradient(
circle at 78% 4%,
rgba(249, 168, 38, 0.14),
transparent 24%
),
radial-gradient(
circle at 50% 90%,
rgba(54, 211, 153, 0.08),
transparent 30%
);
}
a {
@apply text-inherit no-underline;
}
}
@layer components {
.glass-panel {
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
}
}

View File

@@ -0,0 +1,31 @@
import axios from "axios";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api/admin",
});
apiClient.interceptors.request.use((config) => {
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
const sessionToken = window.localStorage.getItem("admin_session");
if (sessionToken) {
config.headers.Authorization = `Bearer ${sessionToken}`;
}
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("admin_tenant");
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
return Promise.reject(error);
},
);
export default apiClient;

View File

@@ -0,0 +1,89 @@
import apiClient from "./apiClient";
export type ClientStatus = "active" | "inactive";
export type ClientType = "confidential" | "public";
export type ClientSummary = {
id: string;
name: string;
type: ClientType;
status: ClientStatus;
createdAt?: string;
redirectUris: string[];
scopes: string[];
};
export type ClientListResponse = {
items: ClientSummary[];
limit: number;
offset: number;
};
export type ClientEndpoints = {
discovery: string;
issuer: string;
authorization: string;
token: string;
userinfo: string;
};
export type ClientDetailResponse = {
client: ClientSummary & {
metadata?: Record<string, unknown>;
};
endpoints: ClientEndpoints;
};
export type ConsentSummary = {
subject: string;
clientId: string;
clientName?: string;
grantedScopes: string[];
authenticatedAt?: string;
};
export type ConsentListResponse = {
items: ConsentSummary[];
};
export async function fetchClients() {
const { data } = await apiClient.get<ClientListResponse>("/clients");
return data;
}
export async function fetchClient(clientId: string) {
const { data } = await apiClient.get<ClientDetailResponse>(
`/clients/${clientId}`,
);
return data;
}
export async function updateClientStatus(
clientId: string,
status: ClientStatus,
) {
const { data } = await apiClient.patch<ClientDetailResponse>(
`/clients/${clientId}/status`,
{ status },
);
return data;
}
export async function fetchConsents(subject: string, clientId?: string) {
const params: Record<string, string> = { subject };
if (clientId) {
params.client_id = clientId;
}
const { data } = await apiClient.get<ConsentListResponse>("/consents", {
params,
});
return data;
}
export async function revokeConsent(subject: string, clientId?: string) {
const params: Record<string, string> = { subject };
if (clientId) {
params.client_id = clientId;
}
await apiClient.delete("/consents", { params });
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

21
devfront/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import "./index.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);