1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/i18n

This commit is contained in:
Lectom C Han
2026-02-13 12:06:37 +09:00
74 changed files with 34674 additions and 343 deletions

View File

@@ -0,0 +1,43 @@
import { ShieldHalf } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
function AuthCallbackPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
useEffect(() => {
const token = searchParams.get("token");
if (token) {
window.localStorage.setItem("admin_session", token);
// 만약 팝업창에서 실행 중이라면 부모 창에 알리고 닫기
if (window.opener) {
window.opener.postMessage({ type: "LOGIN_SUCCESS", token }, "*");
window.close();
} else {
// 일반 리다이렉트 방식인 경우 홈으로 이동
navigate("/", { replace: true });
}
} else {
console.error("No token found in callback URL");
navigate("/login", { replace: true });
}
}, [navigate, searchParams]);
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-lg animate-pulse">
<ShieldHalf size={32} />
</div>
<div className="text-lg font-semibold"> ...</div>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
</div>
);
}
export default AuthCallbackPage;

View File

@@ -0,0 +1,132 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
function LoginPage() {
const navigate = useNavigate();
const [isLoggingIn, setIsLoggingIn] = useState(false);
useEffect(() => {
// Listen for login success message from the popup
const handleMessage = (event: MessageEvent) => {
// Security check: In production, verify event.origin
if (event.data?.type === "LOGIN_SUCCESS" && event.data?.token) {
window.localStorage.setItem("admin_session", event.data.token);
setIsLoggingIn(false);
navigate("/");
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [navigate]);
const handleSSOLogin = () => {
const userfrontUrl = import.meta.env.USERFRONT_URL || "https://sso.hmac.kr";
const callbackUrl = `${window.location.origin}/auth/callback`;
// 항상 redirect_uri를 포함하여 로그인이 성공하면 콜백 페이지로 오도록 함
const loginUrl = `${userfrontUrl}/signin?source=adminfront&redirect_uri=${encodeURIComponent(callbackUrl)}`;
const width = 500;
const height = 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
const popup = window.open(
loginUrl,
"BaronSSOLogin",
`width=${width},height=${height},top=${top},left=${left},status=no,menubar=no,toolbar=no`,
);
if (popup) {
setIsLoggingIn(true);
const timer = setInterval(() => {
if (popup.closed) {
clearInterval(timer);
setIsLoggingIn(false);
}
}, 1000);
} else {
alert("팝업 차단이 설정되어 있습니다. 팝업 허용 후 다시 시도해 주세요.");
}
};
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>
<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={isLoggingIn}
>
{isLoggingIn ? (
<>
<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>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
15 .
<br />
.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
<br />
.
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -216,6 +216,8 @@ function GlobalOverviewPage() {
</CardContent>
</Card>
</div>
<PermissionChecker />
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, Search, ShieldAlert, 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";
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="bg-[var(--color-panel)] border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert size={20} className="text-primary" />
ReBAC
</CardTitle>
<CardDescription>
(Subject) (Object) 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>Namespace</Label>
<select
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">Tenant</option>
<option value="TenantGroup">TenantGroup</option>
<option value="RelyingParty">RelyingParty</option>
<option value="System">System</option>
</select>
</div>
<div className="space-y-2">
<Label>Relation</Label>
<Input
placeholder="view, manage, admins..."
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Object ID</Label>
<Input
placeholder="Tenant UUID 등"
value={object}
onChange={(e) => setObject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Subject (User:ID)</Label>
<Input
placeholder="User:uuid 또는 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 md:w-auto px-12"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button>
</div>
{checkMutation.isSuccess && (
<div
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
result.allowed
? "bg-green-500/10 border-green-500/50 text-green-600"
: "bg-destructive/10 border-destructive/50 text-destructive"
}`}
>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center">
. (
)
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-sm opacity-80 text-center">
.
</p>
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default PermissionChecker;

View File

@@ -0,0 +1,215 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
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";
import {
type TenantGroupSummary,
addGroupAdmin,
fetchGroupAdmins,
fetchUsers,
removeGroupAdmin,
} from "../../../lib/adminApi";
function TenantGroupAdminsTab() {
const { group } = useOutletContext<{
group: TenantGroupSummary;
}>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
// 현재 관리자 목록
const adminsQuery = useQuery({
queryKey: ["tenant-group-admins", group.id],
queryFn: () => fetchGroupAdmins(group.id),
enabled: !!group.id,
});
// 전체 사용자 목록 (관리자 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }],
queryFn: () => fetchUsers(100, 0, searchTerm),
enabled: searchTerm.length > 1, // 2글자 이상 입력 시 검색
});
const addMutation = useMutation({
mutationFn: (userId: string) => addGroupAdmin(group.id, userId),
onSuccess: () => {
adminsQuery.refetch();
setSearchTerm("");
},
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeGroupAdmin(group.id, userId),
onSuccess: () => {
adminsQuery.refetch();
},
});
const handleAddAdmin = (userId: string) => {
addMutation.mutate(userId);
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
removeMutation.mutate(userId);
}
};
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* 현재 그룹 관리자 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 사용자 검색 및 추가 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
</CardTitle>
</div>
<CardDescription>
( ).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 &&
usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupAdminsTab;

View File

@@ -0,0 +1,144 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { LayoutGrid, 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 { createTenantGroup } from "../../../lib/adminApi";
function TenantGroupCreatePage() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const mutation = useMutation({
mutationFn: () =>
createTenantGroup({
name,
slug: slug || name.toLowerCase().replace(/ /g, "-"),
description: description || undefined,
}),
onSuccess: () => {
navigate("/tenant-groups");
},
});
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>Groups</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">Super Admin only</Badge>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutGrid size={18} className="text-primary" />
Group Profile
</CardTitle>
<CardDescription>
(Slug) .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Group Name <span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 바론소프트웨어 통합그룹"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="baron-group"
/>
<p className="text-xs text-muted-foreground">
URL이나 API에서 .
.
</p>
</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)}
placeholder="그룹에 대한 설명을 입력하세요."
/>
</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>
.
</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("/tenant-groups")}>
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
</Button>
</div>
</div>
);
}
export default TenantGroupCreatePage;

View File

@@ -0,0 +1,94 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, LayoutGrid } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { fetchTenantGroup } from "../../../lib/adminApi";
function TenantGroupDetailPage() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const groupQuery = useQuery({
queryKey: ["tenant-group", id],
queryFn: () => fetchTenantGroup(id!),
enabled: !!id,
});
const isTenantsTab = location.pathname.endsWith("/tenants");
const isAdminTab = location.pathname.endsWith("/admins");
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="/tenant-groups"
className="inline-flex items-center gap-2 hover:text-foreground"
>
<ArrowLeft size={14} />
Groups
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<LayoutGrid size={24} className="text-primary" />
</div>
<h2 className="text-3xl font-semibold">
{groupQuery.data?.name ?? "Loading Group..."}
</h2>
</div>
<p className="text-sm text-[var(--color-muted)]">
{groupQuery.data?.description ||
"그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
</p>
</div>
<Badge variant="muted">Super Admin only</Badge>
</header>
{/* Tabs */}
<div className="flex border-b border-border">
<Link
to={`/tenant-groups/${id}`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
!isTenantsTab && !isAdminTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
</Link>
<Link
to={`/tenant-groups/${id}/tenants`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
isTenantsTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
({groupQuery.data?.tenants?.length ?? 0})
</Link>
<Link
to={`/tenant-groups/${id}/admins`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
isAdminTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
</Link>
</div>
<div className="mt-6">
<Outlet
context={{ group: groupQuery.data, refetch: groupQuery.refetch }}
/>
</div>
</div>
);
}
export default TenantGroupDetailPage;

View File

@@ -0,0 +1,172 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { LayoutGrid, 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 { deleteTenantGroup, fetchTenantGroups } from "../../../lib/adminApi";
function TenantGroupListPage() {
const navigate = useNavigate();
const query = useQuery({
queryKey: ["tenant-groups", { limit: 50, offset: 0 }],
queryFn: () => fetchTenantGroups(50, 0),
});
const deleteMutation = useMutation({
mutationFn: (groupId: string) => deleteTenantGroup(groupId),
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 = (groupId: string, groupName: string) => {
if (!window.confirm(`테넌트 그룹 "${groupName}"를 삭제할까요?`)) {
return;
}
deleteMutation.mutate(groupId);
};
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">Groups</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="/tenant-groups/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 className="flex items-center gap-2">
<LayoutGrid size={20} className="text-primary" />
Tenant Group Registry
</CardTitle>
<CardDescription>
{query.data?.total ?? 0}
</CardDescription>
</div>
<Badge variant="muted">Super 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>TENANTS</TableHead>
<TableHead>CREATED</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((group) => (
<TableRow key={group.id}>
<TableCell className="font-semibold">{group.name}</TableCell>
<TableCell>{group.slug}</TableCell>
<TableCell>
<Badge variant="secondary">
{group.tenants?.length ?? 0}
</Badge>
</TableCell>
<TableCell>
{group.createdAt
? new Date(group.createdAt).toLocaleDateString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenant-groups/${group.id}`)}
>
<Pencil size={14} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(group.id, group.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupListPage;

View File

@@ -0,0 +1,104 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
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 {
type TenantGroupSummary,
updateTenantGroup,
} from "../../../lib/adminApi";
function TenantGroupProfileTab() {
const { group, refetch } = useOutletContext<{
group: TenantGroupSummary;
refetch: () => void;
}>();
const queryClient = useQueryClient();
const [name, setName] = useState(group?.name ?? "");
const [description, setDescription] = useState(group?.description ?? "");
const mutation = useMutation({
mutationFn: () => updateTenantGroup(group.id, { name, description }),
onSuccess: () => {
refetch();
queryClient.invalidateQueries({ queryKey: ["tenant-groups"] });
},
});
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
if (!group) return null;
return (
<div className="max-w-2xl space-y-6">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
. (Slug)
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label> ID ( )</Label>
<Input value={group.id} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label>Slug</Label>
<Input value={group.slug} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label htmlFor="groupName">Group Name</Label>
<Input
id="groupName"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="groupDesc">Description</Label>
<Textarea
id="groupDesc"
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
<div className="flex justify-end pt-4">
<Button
onClick={() => mutation.mutate()}
disabled={
mutation.isPending ||
(name === group.name && description === group.description)
}
>
{mutation.isPending ? "저장 중..." : "변경사항 저장"}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupProfileTab;

View File

@@ -0,0 +1,210 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, Plus, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { useOutletContext } 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";
import {
type TenantGroupSummary,
addTenantToGroup,
fetchTenants,
removeTenantFromGroup,
} from "../../../lib/adminApi";
function TenantGroupTenantsTab() {
const { group, refetch } = useOutletContext<{
group: TenantGroupSummary;
refetch: () => void;
}>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
// 전체 테넌트 목록 (할당용)
const tenantsQuery = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const addMutation = useMutation({
mutationFn: (tenantId: string) => addTenantToGroup(group.id, tenantId),
onSuccess: () => {
refetch();
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
},
});
const removeMutation = useMutation({
mutationFn: (tenantId: string) => removeTenantFromGroup(group.id, tenantId),
onSuccess: () => {
refetch();
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
},
});
const handleAddTenant = (tenantId: string) => {
addMutation.mutate(tenantId);
};
const handleRemoveTenant = (tenantId: string) => {
if (window.confirm("이 테넌트를 그룹에서 제외할까요?")) {
removeMutation.mutate(tenantId);
}
};
const availableTenants =
tenantsQuery.data?.items.filter(
(t) => !group.tenants?.some((gt) => gt.id === t.id),
) || [];
const filteredAvailable = availableTenants.filter(
(t) =>
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* 현재 소속 테넌트 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.tenants?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{group.tenants?.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="text-xs">{t.slug}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTenant(t.id)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 추가 가능한 테넌트 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Plus size={18} className="text-primary" />
</CardTitle>
<div className="relative w-48">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="검색..."
className="pl-8 h-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAvailable.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{filteredAvailable.map((t) => (
<TableRow key={t.id}>
<TableCell>
<div className="font-medium">{t.name}</div>
<div className="text-[10px] text-muted-foreground">
{t.slug}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddTenant(t.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupTenantsTab;

View File

@@ -0,0 +1,214 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
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";
import {
addTenantAdmin,
fetchTenantAdmins,
fetchUsers,
removeTenantAdmin,
} from "../../../lib/adminApi";
function TenantAdminsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
if (!tenantId) return null;
// 현재 관리자 목록
const adminsQuery = useQuery({
queryKey: ["tenant-admins", tenantId],
queryFn: () => fetchTenantAdmins(tenantId),
enabled: !!tenantId,
});
// 전체 사용자 목록 (관리자 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }],
queryFn: () => fetchUsers(100, 0, searchTerm),
enabled: searchTerm.length > 1,
});
const addMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onSuccess: () => {
adminsQuery.refetch();
setSearchTerm("");
},
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onSuccess: () => {
adminsQuery.refetch();
},
});
const handleAddAdmin = (userId: string) => {
addMutation.mutate(userId);
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
removeMutation.mutate(userId);
}
};
return (
<div className="grid gap-6 lg:grid-cols-2 mt-6">
{/* 현재 테넌트 관리자 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 사용자 검색 및 추가 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
</CardTitle>
</div>
<CardDescription>
( ).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 &&
usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantAdminsTab;

View File

@@ -16,6 +16,7 @@ function TenantDetailPage() {
});
const isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins");
return (
<div className="space-y-8">
@@ -44,7 +45,9 @@ function TenantDetailPage() {
<Link
to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${
!isFederationTab
!isFederationTab &&
!isAdminTab &&
!location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
@@ -61,6 +64,16 @@ function TenantDetailPage() {
>
Federation
</Link>
<Link
to={`/tenants/${tenantId}/admins`}
className={`px-4 py-2 text-sm font-medium ${
isAdminTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Admins
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${

View File

@@ -18,6 +18,7 @@ import {
approveTenant,
deleteTenant,
fetchTenant,
fetchTenantGroups,
updateTenant,
} from "../../../lib/adminApi";
@@ -35,11 +36,17 @@ export function TenantProfilePage() {
queryFn: () => fetchTenant(tenantId),
});
const groupsQuery = useQuery({
queryKey: ["tenant-groups", { limit: 100 }],
queryFn: () => fetchTenantGroups(100, 0),
});
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [tenantGroupId, setTenantGroupId] = useState("");
useEffect(() => {
if (tenantQuery.data) {
@@ -48,6 +55,7 @@ export function TenantProfilePage() {
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
setTenantGroupId(tenantQuery.data.tenantGroupId ?? "");
}
}, [tenantQuery.data]);
@@ -58,6 +66,7 @@ export function TenantProfilePage() {
slug,
description: description || undefined,
status,
tenantGroupId: tenantGroupId || undefined,
domains: domains
.split(",")
.map((d) => d.trim())
@@ -136,6 +145,25 @@ export function TenantProfilePage() {
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Tenant Group</Label>
<select
value={tenantGroupId}
onChange={(e) => setTenantGroupId(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 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"
>
<option value=""> </option>
{groupsQuery.data?.items.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.slug})
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
.
.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)