1
0
forked from baron/baron-sso

샘플 adminfront, devfront 구성. ory-keto-migrate 오류 해결

This commit is contained in:
Lectom C Han
2026-01-28 14:03:42 +09:00
parent b7a0397ef9
commit e573f4ca50
21 changed files with 1293 additions and 213 deletions

View File

@@ -3,6 +3,10 @@ import AppLayout from "../components/layout/AppLayout";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantCreatePage from "../features/tenants/TenantCreatePage";
import TenantDetailPage from "../features/tenants/TenantDetailPage";
import TenantListPage from "../features/tenants/TenantListPage";
export const router = createBrowserRouter(
[
@@ -10,9 +14,13 @@ export const router = createBrowserRouter(
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ index: true, element: <GlobalOverviewPage /> },
{ path: "dashboard", element: <DashboardPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "tenants/:id", element: <TenantDetailPage /> },
],
},
],

View File

@@ -1,5 +1,6 @@
import {
BadgeCheck,
Building2,
KeyRound,
LayoutDashboard,
Moon,
@@ -12,6 +13,8 @@ import { NavLink, Outlet } from "react-router-dom";
const navItems = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
{ label: "Tenants", to: "/tenants", icon: Building2 },
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
];

View File

@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Filter, ListChecks, Search, Terminal } from "lucide-react";
import { fetchAuditLogs } from "../../lib/adminApi";
@@ -22,7 +23,8 @@ function AuditLogsPage() {
if (error) {
const errMsg =
(error as any).response?.data?.error || (error as Error).message;
(error as AxiosError<{ error?: string }>).response?.data?.error ??
(error as Error).message;
return (
<div className="p-8 text-center text-red-500">
Error loading logs: {errMsg}
@@ -88,10 +90,9 @@ function AuditLogsPage() {
No audit logs found.
</div>
) : (
logs.map((row, idx) => (
logs.map((row) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: simple list
key={`${row.event_type}-${idx}`}
key={`${row.event_type}-${row.user_id}-${row.timestamp}`}
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm"
>
<div className="font-semibold">{row.event_type}</div>

View File

@@ -0,0 +1,170 @@
import {
Activity,
ArrowUpRight,
Box,
Database,
ShieldCheck,
Users,
} 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";
const summaryCards = [
{
label: "Total Tenants",
value: "-",
hint: "Tenant-aware core",
icon: Users,
},
{
label: "OIDC Clients",
value: "-",
hint: "Hydra registry",
icon: ShieldCheck,
},
{
label: "Audit Events (24h)",
value: "-",
hint: "ClickHouse stream",
icon: Activity,
},
{
label: "Policy Gate",
value: "Planned",
hint: "Keto + Admin checks",
icon: Database,
},
];
function GlobalOverviewPage() {
return (
<div className="space-y-10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Global Overview
</p>
<h2 className="text-3xl font-semibold">
Tenant-independent control plane
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">IDP: Ory primary</Badge>
<Badge variant="muted">Fallback: Descope</Badge>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
<Card key={label} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{label}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl">Admin playbook</CardTitle>
<CardDescription>
, , .
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<ShieldCheck size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
Backend-only IDP access
</p>
<p>
IDP backend를 , Hydra/Kratos
admin .
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<Box size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
Tenant isolation
</p>
<p>
Tenant , Keto
.
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl"> </CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/tenants/new">
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/audit-logs">
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/dashboard">
<ArrowUpRight size={16} />
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
export default GlobalOverviewPage;

View File

@@ -0,0 +1,153 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useNavigate } 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 { Textarea } from "../../components/ui/textarea";
import { createTenant } from "../../lib/adminApi";
function TenantCreatePage() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const mutation = useMutation({
mutationFn: () =>
createTenant({
name,
slug: slug || undefined,
description: description || undefined,
status,
}),
onSuccess: () => {
navigate("/tenants");
},
});
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
return (
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span className="text-foreground">Create</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} />
Tenant Profile
</CardTitle>
<CardDescription>
. Slug는 .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="tenant-slug"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
</Button>
</div>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
</CardTitle>
<CardDescription>
Tenant Keto .
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
, .
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenants")}>
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
</Button>
</div>
</div>
);
}
export default TenantCreatePage;

View File

@@ -0,0 +1,185 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Save, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } 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 { Textarea } from "../../components/ui/textarea";
import { deleteTenant, fetchTenant, updateTenant } from "../../lib/adminApi";
function TenantDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const tenantId = useMemo(() => id ?? "", [id]);
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId !== "",
});
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
useEffect(() => {
if (!tenantQuery.data) {
return;
}
setName(tenantQuery.data.name);
setSlug(tenantQuery.data.slug);
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: () =>
updateTenant(tenantId, {
name,
slug,
description: description || undefined,
status,
}),
onSuccess: () => {
navigate("/tenants");
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteTenant(tenantId),
onSuccess: () => {
navigate("/tenants");
},
});
const errorMsg = (updateMutation.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const handleDelete = () => {
if (!window.confirm("이 테넌트를 삭제할까요?")) {
return;
}
deleteMutation.mutate();
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenants" className="inline-flex items-center gap-2">
<ArrowLeft size={14} />
Tenants
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle>Tenant profile</CardTitle>
<CardDescription>Slug와 .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadError && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{loadError}
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
</Button>
</div>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
</CardContent>
</Card>
<div className="flex flex-wrap items-center justify-between gap-3">
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate("/tenants")}>
</Button>
<Button
onClick={() => updateMutation.mutate()}
disabled={
updateMutation.isPending ||
tenantQuery.isLoading ||
name.trim() === ""
}
>
<Save size={16} />
</Button>
</div>
</div>
</div>
);
}
export default TenantDetailPage;

View File

@@ -0,0 +1,171 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Link, useNavigate } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteTenant, fetchTenants } from "../../lib/adminApi";
function TenantListPage() {
const navigate = useNavigate();
const query = useQuery({
queryKey: ["tenants", { limit: 50, offset: 0 }],
queryFn: () => fetchTenants(50, 0),
});
const deleteMutation = useMutation({
mutationFn: (tenantId: string) => deleteTenant(tenantId),
onSuccess: () => {
query.refetch();
},
});
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
const items = query.data?.items ?? [];
const handleDelete = (tenantId: string, tenantName: string) => {
if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
return;
}
deleteMutation.mutate(tenantId);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Tenant registry</CardTitle>
<CardDescription>
{query.data?.total ?? 0}
</CardDescription>
</div>
<Badge variant="muted">Admin only</Badge>
</CardHeader>
<CardContent>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>UPDATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
.
</TableCell>
</TableRow>
)}
{items.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell>{tenant.slug}</TableCell>
<TableCell>
<Badge
variant={tenant.status === "active" ? "default" : "muted"}
>
{tenant.status}
</Badge>
</TableCell>
<TableCell>
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantListPage;

View File

@@ -17,9 +17,80 @@ export type AuditLogListResponse = {
offset: number;
};
export type TenantSummary = {
id: string;
name: string;
slug: string;
description: string;
status: string;
createdAt: string;
updatedAt: string;
};
export type TenantCreateRequest = {
name: string;
slug?: string;
description?: string;
status?: string;
};
export type TenantListResponse = {
items: TenantSummary[];
limit: number;
offset: number;
total: number;
};
export type TenantUpdateRequest = {
name?: string;
slug?: string;
description?: string;
status?: string;
};
export async function fetchAuditLogs(limit = 50, offset = 0) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, offset },
});
return data;
}
export async function fetchTenants(limit = 50, offset = 0) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset },
},
);
return data;
}
export async function fetchTenant(tenantId: string) {
const { data } = await apiClient.get<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
);
return data;
}
export async function createTenant(payload: TenantCreateRequest) {
const { data } = await apiClient.post<TenantSummary>(
"/v1/admin/tenants",
payload,
);
return data;
}
export async function updateTenant(
tenantId: string,
payload: TenantUpdateRequest,
) {
const { data } = await apiClient.put<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
payload,
);
return data;
}
export async function deleteTenant(tenantId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
}