1
0
forked from baron/baron-sso

feat: integrate orgfront and expose internal ids

This commit is contained in:
2026-04-30 09:33:39 +09:00
parent 02375af08d
commit 9ce7a67f58
116 changed files with 22992 additions and 33 deletions

View File

@@ -0,0 +1,422 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronUp,
Copy,
Download,
RefreshCw,
Search,
} from "lucide-react";
import * as React from "react";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
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 { DevAuditLog } from "../../lib/devApi";
import { fetchDevAuditLogs } from "../../lib/devApi";
import { t } from "../../lib/i18n";
type AuditDetails = {
request_id?: string;
method?: string;
path?: string;
tenant_id?: string;
action?: string;
target_id?: string;
before?: unknown;
after?: unknown;
error?: string;
};
function parseDetails(details?: string): AuditDetails {
if (!details) {
return {};
}
try {
const parsed = JSON.parse(details);
if (parsed && typeof parsed === "object") {
return parsed as AuditDetails;
}
} catch {}
return {};
}
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "-";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function formatDateTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString("ko-KR");
}
function toCsv(logs: DevAuditLog[]) {
const header = [
"timestamp",
"user_id",
"status",
"event_type",
"action",
"target_id",
"tenant_id",
"request_id",
];
const rows = logs.map((logItem) => {
const details = parseDetails(logItem.details);
return [
logItem.timestamp,
logItem.user_id || "",
logItem.status,
logItem.event_type,
details.action || "",
details.target_id || "",
details.tenant_id || "",
details.request_id || "",
];
});
return [header, ...rows]
.map((line) =>
line.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(","),
)
.join("\n");
}
function downloadCsv(content: string, filename: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
function AuditLogsPage() {
const [searchClientId, setSearchClientId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const query = useInfiniteQuery({
queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter],
queryFn: ({ pageParam }) =>
fetchDevAuditLogs(50, pageParam, {
client_id: searchClientId.trim() || undefined,
action: searchAction.trim() || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const logs =
query.data?.pages.flatMap((page) =>
page.items.filter((item): item is DevAuditLog => Boolean(item)),
) ?? [];
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const handleExportCsv = () => {
const csv = toCsv(logs);
const stamp = new Date().toISOString().replaceAll(":", "-");
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
};
if (query.isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.audit.loading", "Loading audit logs...")}
</div>
);
}
if (query.error) {
const axiosError = query.error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
return <ForbiddenMessage resourceToken="audit" />;
}
const errMsg =
axiosError.response?.data?.error ?? (query.error as Error).message;
return (
<div className="p-8 text-center text-red-500">
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
error: errMsg,
})}
</div>
);
}
return (
<div className="space-y-6">
<Card className="glass-panel">
<CardHeader 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-muted-foreground">
{t("ui.dev.audit.registry.title", "Audit registry")}
</p>
<CardTitle className="text-3xl font-black tracking-tight">
{t("ui.dev.audit.title", "Audit Logs")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.audit.subtitle",
"Shows DevFront activity history within current tenant/app scope.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
count: logs.length,
})}
</Badge>
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button
className="shadow-sm shadow-primary/30"
onClick={handleExportCsv}
>
<Download size={16} />
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 md:grid-cols-[1fr,1fr,180px]">
<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"
value={searchClientId}
onChange={(e) => setSearchClientId(e.target.value)}
placeholder={t(
"ui.dev.audit.filter.client_id",
"Filter by Client ID",
)}
/>
</div>
<Input
value={searchAction}
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
placeholder={t(
"ui.dev.audit.filter.action",
"Filter by Action (e.g. ROTATE_SECRET)",
)}
/>
<select
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.audit.filter.status_all", "All Status")}
</option>
<option value="success">
{t("ui.common.status.success", "Success")}
</option>
<option value="failure">
{t("ui.common.status.failure", "Failure")}
</option>
</select>
</div>
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[190px]">
{t("ui.dev.audit.table.time", "Time")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.dev.audit.table.actor", "Actor")}
</TableHead>
<TableHead className="w-[180px]">
{t("ui.dev.audit.table.action", "Action")}
</TableHead>
<TableHead className="w-[260px]">
{t("ui.dev.audit.table.target", "Target")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.dev.audit.table.status", "Status")}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("msg.dev.audit.empty", "No audit logs found.")}
</TableCell>
</TableRow>
)}
{logs.map((row, index) => {
const details = parseDetails(row.details);
const actionLabel = details.action || row.event_type;
const targetValue = details.target_id || "-";
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
return (
<React.Fragment key={rowKey}>
<TableRow>
<TableCell className="text-xs text-muted-foreground">
{formatDateTime(row.timestamp)}
</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span>{row.user_id || "-"}</span>
{row.user_id ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(row.user_id)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell className="text-xs">{actionLabel}</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
<span className="break-all">{targetValue}</span>
{targetValue !== "-" ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => handleCopy(targetValue)}
>
<Copy className="h-3 w-3" />
</Button>
) : null}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success" ? "success" : "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{expanded ? (
<TableRow className="bg-card/20">
<TableCell
colSpan={6}
className="text-xs text-muted-foreground"
>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<div>
Request ID: {formatValue(details.request_id)}
</div>
<div>Method: {formatValue(details.method)}</div>
<div>Path: {formatValue(details.path)}</div>
<div>
Tenant: {formatValue(details.tenant_id)}
</div>
</div>
<div className="space-y-1 break-all">
<div>Before: {formatValue(details.before)}</div>
<div>After: {formatValue(details.after)}</div>
<div>Error: {formatValue(details.error)}</div>
</div>
</div>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})}
</TableBody>
</Table>
{query.hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
>
{query.isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.dev.audit.load_more", "Load more")}
</Button>
</div>
) : null}
</CardContent>
</Card>
</div>
);
}
export default AuditLogsPage;

View File

@@ -0,0 +1,35 @@
import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { userManager } from "../../lib/auth";
export default function AuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
// 팝업으로 열린 경우 signinPopupCallback 처리
if (window.opener) {
userManager.signinPopupCallback().catch((error) => {
console.error("Popup callback failed:", error);
});
return;
}
if (auth.isAuthenticated) {
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/chart";
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
return <div>Loading Auth...</div>;
}

View File

@@ -0,0 +1,60 @@
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { t } from "../../lib/i18n";
export default function AuthGuard() {
const auth = useAuth();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const shareToken = searchParams.get("token");
// 공유 토큰이 있는 경우 인증 체크를 건너뜁니다 (Public View)
if (shareToken) {
return <Outlet />;
}
if (auth.isLoading || auth.activeNavigator) {
return <div>Loading...</div>;
}
if (auth.error) {
return <div>Auth Error: {auth.error.message}</div>;
}
if (!auth.isAuthenticated) {
return <Navigate to="/login" replace />;
}
// 조직도 앱은 일반 사용자(user)도 볼 수 있어야 하므로 접근 제한을 해제합니다.
const isDenied = false; // normalizedRole === "guest";
if (isDenied) {
return (
<div className="min-h-screen grid place-items-center bg-background text-foreground p-6">
<div className="max-w-lg w-full rounded-xl border border-border bg-card p-6 space-y-4">
<h1 className="text-xl font-semibold">
{t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")}
</h1>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.auth.access_denied_description",
"조직도를 볼 수 있는 권한이 없습니다.",
)}
</p>
<button
type="button"
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
onClick={() => {
auth.removeUser();
window.location.href = "/login";
}}
>
{t("ui.common.back_to_login", "로그인으로 돌아가기")}
</button>
</div>
</div>
);
}
return <Outlet />;
}

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,126 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/chart";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
if (auth.isAuthenticated) {
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = async () => {
try {
await auth.signinRedirect({
state: {
returnTo: "/chart",
},
});
} catch (error) {
console.error("Redirect login failed", error);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
<div className="w-full max-w-md space-y-8">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
<ShieldHalf size={32} />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
Developer Control Plane
</p>
</div>
</div>
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
</CardTitle>
<CardDescription>
Baron (SSO) .
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
>
{auth.isLoading ? (
<>
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...
</>
) : (
<>
<ShieldHalf size={22} />
SSO
<ExternalLink size={16} className="opacity-50" />
</>
)}
</Button>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
.
<br />
.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
<br />
.
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -0,0 +1,24 @@
import apiClient from "../../lib/apiClient";
export interface Tenant {
id: string;
name: string;
phone?: string;
slug: string;
}
export interface UserProfile {
id: string;
email: string;
name: string;
phone?: string;
role: string;
companyCode?: string;
tenantId?: string;
tenant?: Tenant;
}
export async function fetchMe() {
const { data } = await apiClient.get<UserProfile>("/user/me");
return data;
}

View File

@@ -0,0 +1,600 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Download,
Filter,
Search,
} from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
function ClientConsentsPage() {
const params = useParams();
const clientId = params.id ?? "";
const [subjectInput, setSubjectInput] = useState("");
const [subject, setSubject] = useState("");
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [scopeFilter, setScopeFilter] = useState<string[]>([]);
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const { data: clientData } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
});
const {
data: consentsData,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["consents", clientId, subject],
queryFn: () => fetchConsents(subject, clientId, "all"),
enabled: clientId.length > 0,
});
const revokeMutation = useMutation({
mutationFn: (payload: { subject: string }) =>
revokeConsent(payload.subject, clientId),
onSuccess: () => {
refetch();
},
});
const handleRevoke = (sub: string) => {
if (
window.confirm(
t(
"msg.dev.clients.consents.revoke_confirm",
"정말로 이 사용자의 권한을 철회하시겠습니까? 철회 시 사용자는 다음 접속 시 다시 동의해야 합니다.",
),
)
) {
revokeMutation.mutate({ subject: sub });
}
};
const rows = consentsData?.items ?? [];
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
const filteredRows = rows.filter((row) => {
const matchStatus =
statusFilter.length === 0 || statusFilter.includes(row.status);
const matchScope =
scopeFilter.length === 0 ||
scopeFilter.some((s) => row.grantedScopes.includes(s));
return matchStatus && matchScope;
});
const handleExportCSV = () => {
if (filteredRows.length === 0) return;
const headers = [
t("ui.dev.clients.consents.table.user", "User"),
t("ui.dev.clients.consents.table.tenant", "Tenant"),
t("ui.dev.clients.table.status", "Status"),
t("ui.dev.clients.consents.table.scopes", "Granted Scopes"),
t("ui.dev.clients.consents.table.first_granted", "First Granted"),
t(
"ui.dev.clients.consents.table.last_auth",
"Last Authenticated / Revoked",
),
];
const csvContent = [
headers.join(","),
...filteredRows.map((row) => {
const lastAuthRevoked =
row.status === "revoked" && row.deletedAt
? `${t("ui.dev.clients.consents.status_revoked", "Revoked")}: ${new Date(row.deletedAt).toLocaleString()}`
: row.authenticatedAt
? new Date(row.authenticatedAt).toLocaleString()
: "-";
return [
`"${row.subject} (${row.userName || ""})"`,
`"${row.tenantName || row.tenantId || ""}"`,
`"${row.status}"`,
`"${row.grantedScopes.join(", ")}"`,
`"${new Date(row.createdAt).toLocaleString()}"`,
`"${lastAuthRevoked}"`,
].join(",");
}),
].join("\n");
const blob = new Blob([`\uFEFF${csvContent}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const date = new Date().toISOString().split("T")[0];
link.setAttribute("href", url);
link.setAttribute("download", `consents_${clientId}_${date}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleStatusFilterChange = (status: string, checked: boolean) => {
if (checked) {
setStatusFilter((prev) => [...prev, status]);
} else {
setStatusFilter((prev) => prev.filter((s) => s !== status));
}
};
const handleScopeFilterChange = (scope: string, checked: boolean) => {
if (checked) {
setScopeFilter((prev) => [...prev, scope]);
} else {
setScopeFilter((prev) => prev.filter((s) => s !== scope));
}
};
const handleAllScopesChange = (checked: boolean) => {
if (checked) {
setScopeFilter(allScopes);
} else {
setScopeFilter([]);
}
};
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">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{clientData?.client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t(
"ui.dev.clients.consents.breadcrumb.current",
"User Consent Grants",
)}
</span>
</nav>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to={`/clients/${clientId}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<p className="text-3xl font-black leading-tight">
{t("ui.dev.clients.consents.title", "User Consent Grants")}
</p>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.consents.subtitle",
"OIDC Relying Party 사용자 권한을 검토·관리합니다.",
)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Badge
variant={
clientData?.client?.status === "active" ? "info" : "muted"
}
>
{clientData?.client?.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</div>
</div>
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
<Link
to={`/clients/${clientId}`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.connection", "Federation")}
</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
</span>
<Link
to={`/clients/${clientId}/settings`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.settings", "Settings")}
</Link>
</div>
</header>
<Card className="glass-panel">
<CardContent className="space-y-4">
<div 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={t(
"ui.dev.clients.consents.search_placeholder",
"사용자 ID, 이름, 이메일로 검색",
)}
value={subjectInput}
onChange={(e) => setSubjectInput(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)}
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
>
<Filter className="h-4 w-4" />
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
<Button
className="shadow-sm shadow-primary/30"
onClick={() => setSubject(subjectInput.trim())}
>
{t("ui.common.search", "검색")}
</Button>
<Button
className="shadow-sm shadow-primary/30"
onClick={handleExportCSV}
disabled={filteredRows.length === 0}
>
<Download className="h-4 w-4" />
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
</Button>
</div>
</div>
{isAdvancedFilterOpen && (
<div className="flex flex-col gap-4 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={statusFilter.includes("active")}
onChange={(e) =>
handleStatusFilterChange("active", e.target.checked)
}
/>
{t("ui.common.status.active", "Active")}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={statusFilter.includes("revoked")}
onChange={(e) =>
handleStatusFilterChange("revoked", e.target.checked)
}
/>
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
</label>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.dev.clients.consents.scope_label", "Scope:")}
</span>
<div className="flex flex-wrap gap-4">
{allScopes.length > 0 && (
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={
scopeFilter.length === allScopes.length &&
allScopes.length > 0
}
onChange={(e) =>
handleAllScopesChange(e.target.checked)
}
/>
ALL
</label>
)}
{allScopes.map((scope) => (
<label
key={scope}
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
>
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={scopeFilter.includes(scope)}
onChange={(e) =>
handleScopeFilterChange(scope, e.target.checked)
}
/>
{scope}
</label>
))}
</div>
</div>
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground p-0 h-auto"
onClick={() => {
setStatusFilter([]);
setScopeFilter([]);
}}
>
{t("ui.common.reset", "초기화")}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="glass-panel">
{error && (
<CardContent className="text-sm text-red-500">
{t(
"msg.dev.clients.consents.load_error",
"Error loading consents: {{error}}",
{
error: (error as Error).message,
},
)}
</CardContent>
)}
{isLoading && (
<CardContent className="text-sm text-muted-foreground">
{t("msg.dev.clients.consents.loading", "Loading consents...")}
</CardContent>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.dev.clients.consents.table.user", "User")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.tenant", "Tenant")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.status", "Status")}
</TableHead>
<TableHead>
{t("ui.dev.clients.consents.table.scopes", "Granted Scopes")}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.first_granted",
"First Granted",
)}
</TableHead>
<TableHead>
{t(
"ui.dev.clients.consents.table.last_auth",
"Last Authenticated / Revoked",
)}
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.consents.table.action", "Action")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
{t("msg.dev.clients.consents.empty", "No consents found.")}
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow
key={`${row.subject}-${row.clientId}`}
className={row.status === "revoked" ? "opacity-60" : ""}
>
<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.userName || row.subject)
.slice(0, 2)
.toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.userName ||
t("ui.dev.clients.consents.subject", "Subject")}
</span>
<span className="text-xs text-muted-foreground">
{row.subject}
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-sm font-semibold">
{row.tenantName || t("ui.common.na", "N/A")}
</span>
<span className="text-xs text-muted-foreground">
{row.tenantId}
</span>
</div>
</TableCell>
<TableCell>
{row.status === "active" ? (
<Badge variant="success">
{t("ui.common.status.active", "Active")}
</Badge>
) : (
<Badge variant="warning">
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.grantedScopes.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">
{new Date(row.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.status === "revoked" && row.deletedAt ? (
<span className="text-destructive font-medium">
{t("ui.dev.clients.consents.revoked_at", "Revoked: ")}
{new Date(row.deletedAt).toLocaleString()}
</span>
) : row.authenticatedAt ? (
new Date(row.authenticatedAt).toLocaleString()
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
{row.status === "active" && (
<Button
variant="ghost"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleRevoke(row.subject)}
disabled={revokeMutation.isPending}
>
{t("ui.dev.clients.consents.revoke", "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>
{t(
"msg.dev.clients.consents.showing",
"Showing {{from}} to {{to}} of {{total}} users",
{
from: filteredRows.length > 0 ? 1 : 0,
to: filteredRows.length,
total: rows.length,
},
)}
</p>
<div className="flex gap-2">
<Button variant="outline" size="icon" disabled>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="sm" disabled={filteredRows.length === 0}>
1
</Button>
<Button variant="outline" size="icon" disabled>
<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">
{t(
"ui.dev.clients.consents.stats.active_grants",
"Active Grants",
)}
</p>
<CardTitle className="text-2xl font-black">
{rows.filter((r) => r.status === "active").length}
</CardTitle>
</CardHeader>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t(
"ui.dev.clients.consents.stats.total_scopes",
"Total Scopes Issued",
)}
</p>
<CardTitle className="text-2xl font-black">
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
</CardTitle>
</CardHeader>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t(
"ui.dev.clients.consents.stats.avg_scopes",
"Avg. Scopes per User",
)}
</p>
<CardTitle className="text-2xl font-black">
{rows.length > 0
? (
rows.reduce(
(acc, row) => acc + row.grantedScopes.length,
0,
) / rows.length
).toFixed(1)
: "0.0"}
</CardTitle>
</CardHeader>
</Card>
</div>
</div>
);
}
export default ClientConsentsPage;

View File

@@ -0,0 +1,540 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
Eye,
EyeOff,
Link2,
RefreshCw,
Save,
Shield,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, 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 { CopyButton } from "../../components/ui/copy-button";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import {
fetchClient,
rotateClientSecret,
updateClient,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
function ClientDetailsPage() {
const params = useParams();
const queryClient = useQueryClient();
const clientId = params.id ?? "";
const { data, error, isLoading } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
});
const [redirectUris, setRedirectUris] = useState("");
const [showSecret, setShowSecret] = useState(false);
const redirectUrisHydratedRef = useRef(false);
useEffect(() => {
if (
!redirectUrisHydratedRef.current &&
data?.client?.redirectUris &&
redirectUris === ""
) {
setRedirectUris(data.client.redirectUris.join(", "));
redirectUrisHydratedRef.current = true;
}
}, [data, redirectUris]);
const mutation = useMutation({
mutationFn: () => {
const uriList = redirectUris
.split(",")
.map((u) => u.trim())
.filter(Boolean);
return updateClient(clientId, { redirectUris: uriList });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast(
t(
"msg.dev.clients.details.redirect_saved",
"Redirect URIs가 저장되었습니다.",
),
);
},
onError: (err) => {
toast(
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
error: (err as Error).message,
}),
"error",
);
},
});
const rotateMutation = useMutation({
mutationFn: () => rotateClientSecret(clientId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast(
t(
"msg.dev.clients.details.secret_rotated",
"Client Secret이 재발급되었습니다.",
),
);
setShowSecret(true); // 재발급 후 바로 보여줌
},
onError: (err) => {
toast(
t("msg.dev.clients.details.rotate_error", "재발급 실패: {{error}}", {
error: (err as Error).message,
}),
"error",
);
},
});
const handleRotateSecret = () => {
if (
window.confirm(
t(
"msg.dev.clients.details.rotate_confirm",
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
),
)
) {
rotateMutation.mutate();
}
};
if (!clientId) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
</div>
);
}
if (error && !data) {
const errMsg =
(error as AxiosError<{ error?: string }>).response?.data?.error ??
(error as Error)?.message;
return (
<div className="p-8 text-center text-red-500">
{t(
"msg.dev.clients.details.load_error",
"Error loading app: {{error}}",
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
)}
</div>
);
}
if (isLoading && !data) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading app details...")}
</div>
);
}
const client = data?.client;
if (!client) {
return null;
}
const endpointValues = data?.endpoints ?? {
discovery: "-",
issuer: "-",
authorization: "-",
token: "-",
userinfo: "-",
};
const endpoints = [
{
labelKey: "ui.dev.clients.details.endpoint.discovery",
labelFallback: "Discovery Endpoint",
value: endpointValues.discovery,
},
{
labelKey: "ui.dev.clients.details.endpoint.issuer",
labelFallback: "Issuer URL",
value: endpointValues.issuer,
},
{
labelKey: "ui.dev.clients.details.endpoint.authorization",
labelFallback: "Authorization Endpoint",
value: endpointValues.authorization,
},
{
labelKey: "ui.dev.clients.details.endpoint.token",
labelFallback: "Token Endpoint",
value: endpointValues.token,
},
{
labelKey: "ui.dev.clients.details.endpoint.userinfo",
labelFallback: "UserInfo Endpoint",
value: endpointValues.userinfo,
},
];
// Client Secret from API
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = client?.clientSecret || secretPlaceholder;
const displaySecret =
clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret;
return (
<div className="space-y-8">
<div className="space-y-3">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.connection", "Federation")}
</span>
</nav>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to="/clients">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-4xl font-black leading-tight tracking-tight">
{client?.name || client?.id || clientId}
</h1>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.details.subtitle",
"Manage OIDC credentials and endpoints.",
)}
</p>
</div>
</div>
<Badge
variant={client?.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{client?.status === "active"
? t("ui.common.status.active", "Active")
: client?.status === "inactive"
? t("ui.common.status.inactive", "Inactive")
: t("msg.common.loading", "Loading...")}
</Badge>
</div>
<div className="flex gap-6 border-b border-border">
<Link
to={`/clients/${clientId}`}
className="border-b-2 border-primary pb-3 text-sm font-bold text-primary"
>
{t("ui.dev.clients.details.tab.connection", "Federation")}
</Link>
<Link
to={`/clients/${clientId}/consents`}
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.consents", "Consent & Users")}
</Link>
<Link
to={`/clients/${clientId}/settings`}
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.settings", "Settings")}
</Link>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold">
{t(
"ui.dev.clients.details.credentials.title",
"Client Credentials",
)}
</h2>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 p-6">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t(
"ui.dev.clients.details.credentials.client_id",
"Client ID",
)}
</p>
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-lg truncate">
{client?.id || clientId}
</p>
<CopyButton
value={client?.id || clientId}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_id",
"Client ID가 복사되었습니다.",
),
)
}
/>
</div>
</div>
<Separator />
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t(
"ui.dev.clients.details.credentials.client_secret",
"Client Secret",
)}
</p>
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"font-mono text-lg",
!showSecret && "tracking-widest",
)}
>
{showSecret ? displaySecret : "••••••••••••••••"}
</p>
<div className="flex gap-2 shrink-0">
<Button
variant="secondary"
size="icon"
onClick={() => setShowSecret(!showSecret)}
aria-label={
showSecret
? t(
"ui.dev.clients.details.secret.hide",
"비밀키 숨기기",
)
: t(
"ui.dev.clients.details.secret.show",
"비밀키 보기",
)
}
>
{showSecret ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="secondary"
size="icon"
onClick={handleRotateSecret}
disabled={rotateMutation.isPending}
title={t(
"ui.dev.clients.details.secret.rotate",
"비밀키 재발급 (Rotate)",
)}
>
<RefreshCw
className={cn(
"h-4 w-4",
rotateMutation.isPending && "animate-spin",
)}
/>
</Button>
<CopyButton
value={clientSecret}
disabled={
!showSecret && clientSecret === secretPlaceholder
}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_secret",
"Client Secret이 복사되었습니다.",
),
)
}
/>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">
{t("ui.dev.clients.details.endpoints.title", "OIDC 엔드포인트")}
</h2>
<Badge variant="muted" className="gap-1">
<Link2 className="h-3 w-3" />
{t("ui.dev.clients.details.endpoints.read_only", "읽기 전용")}
</Badge>
</div>
<Card className="glass-panel">
<Table>
<TableBody>
{endpoints.map((endpoint) => (
<TableRow
key={endpoint.labelKey}
className="border-border/70"
>
<TableCell className="w-1/3">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
{t(endpoint.labelKey, endpoint.labelFallback)}
</p>
</TableCell>
<TableCell className="flex items-center justify-between gap-3">
<span className="break-all font-mono text-sm">
{endpoint.value}
</span>
<CopyButton
value={endpoint.value}
className="h-8 w-8 shrink-0"
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_endpoint",
"{{label}}가 복사되었습니다.",
{
label: t(
endpoint.labelKey,
endpoint.labelFallback,
),
},
),
)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
</div>
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold">
{t("ui.dev.clients.details.redirect.title", "리디렉션 URI 설정")}
</h2>
<Card className="glass-panel border-primary/20">
<CardHeader>
<CardTitle className="text-lg">
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.details.redirect.description",
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label
htmlFor="redirect-uris"
className="text-sm font-semibold"
>
{t(
"ui.dev.clients.details.redirect.callback_label",
"인증 콜백 URL",
)}
</Label>
<Textarea
id="redirect-uris"
placeholder={t(
"ui.dev.clients.details.redirect.placeholder",
"https://your-app.com/callback, http://localhost:3000/auth/callback",
)}
rows={5}
value={redirectUris}
onChange={(e) => {
redirectUrisHydratedRef.current = true;
setRedirectUris(e.target.value);
}}
className="font-mono text-sm"
/>
</div>
<Button
className="w-full gap-2"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Save className="h-4 w-4" />
{mutation.isPending
? t("msg.common.saving", "저장 중...")
: t(
"ui.dev.clients.details.redirect.save",
"Redirect URIs 저장",
)}
</Button>
</CardContent>
</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">
{t("ui.dev.clients.details.security.title", "보안 메모")}
</p>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.security.note",
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
)}
</p>
</div>
</div>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.security.footer",
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
)}
</p>
</div>
</div>
</div>
</div>
);
}
export default ClientDetailsPage;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,527 @@
import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
BookOpenText,
Filter,
Plus,
Search,
ServerCog,
ShieldHalf,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
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, fetchDevStats } from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const {
data,
isLoading: isLoadingClients,
error: clientError,
} = useQuery({
queryKey: ["clients"],
queryFn: fetchClients,
enabled: hasAccessToken,
});
const { data: statsData, isLoading: isLoadingStats } = useQuery({
queryKey: ["dev-stats"],
queryFn: fetchDevStats,
enabled: hasAccessToken,
});
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const clients = data?.items || [];
const filteredClients = clients.filter((client) => {
const matchesSearch =
!searchQuery ||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = typeFilter === "all" || client.type === typeFilter;
const matchesStatus =
statusFilter === "all" || client.status === statusFilter;
return matchesSearch && matchesType && matchesStatus;
});
const totalClients = statsData?.total_clients ?? clients.length;
const activeSessions = statsData?.active_sessions ?? 0;
const authFailures = statsData?.auth_failures_24h ?? 0;
type StatTone = "up" | "down" | "stable";
type StatItem = {
labelKey: string;
labelFallback: string;
value: string;
deltaKey: string;
deltaFallback: string;
tone: StatTone;
};
const stats: StatItem[] = [
{
labelKey: "ui.dev.clients.stats.total",
labelFallback: "Total Applications",
value: totalClients.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.active_sessions",
labelFallback: "Active Sessions",
value: activeSessions.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.auth_failures",
labelFallback: "Auth Failures (24h)",
value: authFailures.toString(),
deltaKey:
authFailures > 0
? "ui.dev.clients.stats.alert"
: "ui.dev.clients.stats.stable",
deltaFallback: authFailures > 0 ? "Check Logs" : "Stable",
tone: authFailures > 0 ? ("down" as const) : ("stable" as const),
},
];
const isLoading = isLoadingClients || isLoadingStats;
if (auth.isLoading || !hasAccessToken || isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.loading", "Loading clients...")}
</div>
);
}
if (clientError) {
const axiosError = clientError as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
return <ForbiddenMessage resourceToken="clients" />;
}
const errMsg =
axiosError.response?.data?.error ?? (clientError as Error).message;
return (
<div className="p-8 text-center text-red-500">
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
error: 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">
{t("ui.dev.clients.registry.title", "RP registry")}
</p>
<CardTitle className="text-3xl font-black tracking-tight">
{t("ui.dev.clients.registry.subtitle", "연동 앱")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.registry.description",
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
)}
</CardDescription>
</div>
<div className="hidden items-center gap-2 md:flex">
<Button
size="sm"
className="shadow-lg shadow-primary/30"
onClick={() => navigate("/clients/new")}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
placeholder={t(
"ui.dev.clients.search_placeholder",
"클라이언트 이름/ID로 검색...",
)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)}
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
>
<Filter className="h-4 w-4" />
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
<div className="hidden items-center gap-2 md:flex">
<Badge variant="muted">
{t(
"ui.dev.clients.badge.tenant_selected",
"테넌트: 선택됨",
)}
</Badge>
<Badge variant="success">
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
</Badge>
</div>
</div>
</div>
{isAdvancedFilterOpen && (
<div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.filter.type_label", "Type:")}
</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.filter.type_all", "모든 유형")}
</option>
<option value="private">
{t("ui.dev.clients.type.private", "Server side App")}
</option>
<option value="pkce">
{t("ui.dev.clients.type.pkce", "PKCE")}
</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.filter.status_all", "모든 상태")}
</option>
<option value="active">
{t("ui.common.status.active", "Active")}
</option>
<option value="inactive">
{t("ui.common.status.inactive", "Inactive")}
</option>
</select>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground ml-auto"
onClick={() => {
setTypeFilter("all");
setStatusFilter("all");
}}
>
{t("ui.common.reset", "초기화")}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid gap-4 md:grid-cols-3">
{stats.map((item) => (
<Card key={item.labelKey} className="border border-border/60">
<CardHeader className="pb-2">
<CardDescription>
{t(item.labelKey, item.labelFallback)}
</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 === "stable" && "bg-muted/40 text-foreground",
)}
>
{t(item.deltaKey, item.deltaFallback)}
</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">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
<div className="flex items-center gap-2 md:hidden">
<Button size="sm" onClick={() => navigate("/clients/new")}>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.dev.clients.table.application", "애플리케이션")}
</TableHead>
<TableHead>
{t("ui.dev.clients.table.client_id", "Client ID")}
</TableHead>
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
<TableHead>
{t("ui.dev.clients.table.status", "상태")}
</TableHead>
<TableHead>
{t("ui.dev.clients.table.created_at", "생성일")}
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredClients.map((client) => (
<TableRow key={client.id} className="bg-card/40">
<TableCell>
<Link
to={`/clients/${client.id}`}
className="flex items-center gap-3 transition-colors hover:text-primary"
>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
{client.type === "private" ? (
<ServerCog className="h-4 w-4" />
) : (
<ShieldHalf className="h-4 w-4" />
)}
</div>
<div>
<p className="font-semibold">
{client.name ||
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
{t("ui.dev.clients.tenant_scoped", "Tenant-scoped")}
</p>
</div>
</Link>
</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>
</div>
</TableCell>
<TableCell>
<Badge
variant={client.type === "private" ? "success" : "muted"}
>
{client.type === "private"
? t("ui.dev.clients.type.private", "Server side App")
: client.metadata?.headless_login_enabled
? t(
"ui.dev.clients.type.pkce_headless",
"PKCE (Headless Login)",
)
: t("ui.dev.clients.type.pkce", "PKCE")}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={client.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{client.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</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}`}>
{t("ui.common.view", "View")}
</Link>
</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>
{t(
"msg.dev.clients.showing",
"Showing {{shown}} of {{total}} clients",
{ shown: filteredClients.length, total: totalClients },
)}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled>
{t("ui.common.previous", "Previous")}
</Button>
<Button variant="outline" size="sm" disabled>
{t("ui.common.next", "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">
{t(
"ui.dev.clients.help.title",
"Need help with OIDC configuration?",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.help.subtitle",
"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">
{t("ui.dev.clients.help.docs_title", "Docs & Examples")}
</p>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.help.docs_body",
"Includes PKCE, client_secret_basic, redirect URI validation tips.",
)}
</p>
</div>
</div>
<Button variant="secondary">
{t("ui.dev.clients.help.view_guides", "View guides")}
</Button>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-semibold">
{t("ui.dev.clients.owner.title", "Owner")}
</CardTitle>
<CardDescription>
{t("ui.dev.clients.owner.subtitle", "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={t("ui.dev.clients.owner.avatar_alt", "ops user")}
/>
<AvatarFallback>AR</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">
{t("ui.dev.clients.owner.name", "AI Admin Bot")}
</p>
<p className="text-xs text-muted-foreground">
{t("ui.dev.clients.owner.email", "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>
{t("ui.dev.clients.owner.role", "Role: Tenant Admin")}
</span>
<span>{t("ui.dev.clients.owner.scope", "Scope: TENANT-12")}</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export default ClientsPage;

View File

@@ -0,0 +1,306 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit, Globe, Plus, Save, Trash2 } 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 {
createIdpConfigForClient,
listIdpConfigsForClient,
} from "../../../lib/devApi";
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
import { t } from "../../../lib/i18n";
// Proper Modal Component with Form
const CreateIdpModal = ({
isOpen,
onClose,
clientId,
}: {
isOpen: boolean;
onClose: () => void;
clientId: string;
}) => {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<IdpConfigCreateRequest>({
client_id: clientId,
provider_type: "oidc",
display_name: "",
status: "active",
issuer_url: "",
oidc_client_id: "",
oidc_client_secret: "",
scopes: "openid email profile",
});
const mutation = useMutation({
mutationFn: (newData: IdpConfigCreateRequest) =>
createIdpConfigForClient(newData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["idpConfigs", clientId] });
onClose();
},
onError: (error) => {
alert(`Failed to create configuration: ${error.message}`);
},
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 backdrop-blur-sm">
<Card className="w-full max-w-lg shadow-2xl animate-in zoom-in-95 duration-200">
<CardHeader>
<CardTitle>
{t("ui.dev.clients.federation.add_title", "Add Identity Provider")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.federation.add_subtitle",
"Connect an external OIDC provider.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-semibold">Display Name</label>
<Input
name="display_name"
value={formData.display_name}
onChange={handleChange}
placeholder="e.g. Google Workspace"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Issuer URL</label>
<Input
type="url"
name="issuer_url"
value={formData.issuer_url}
onChange={handleChange}
placeholder="https://accounts.google.com"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">Client ID</label>
<Input
name="oidc_client_id"
value={formData.oidc_client_id}
onChange={handleChange}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Client Secret</label>
<Input
type="password"
name="oidc_client_secret"
value={formData.oidc_client_secret}
onChange={handleChange}
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Scopes</label>
<Input
name="scopes"
value={formData.scopes}
onChange={handleChange}
/>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t("ui.common.cancel", "Cancel")}
</Button>
<Button
type="submit"
disabled={
mutation.isPending ||
formData.display_name.trim() === "" ||
(formData.issuer_url?.trim() ?? "") === ""
}
>
{mutation.isPending ? (
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
) : (
<Save size={16} className="mr-2" />
)}
{mutation.isPending
? t("msg.common.saving", "Saving...")
: t("ui.common.save", "Save Configuration")}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export function ClientFederationPage() {
const { id: clientId } = useParams<{ id: string }>();
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
if (!clientId) {
return (
<div className="p-8 text-center text-destructive">
Client ID is missing
</div>
);
}
const { data, isLoading, error } = useQuery({
queryKey: ["idpConfigs", clientId],
queryFn: () => listIdpConfigsForClient(clientId),
});
return (
<div className="space-y-6 p-1">
<header className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Globe className="h-6 w-6 text-primary" />
{t("ui.dev.clients.federation.title", "Identity Federation")}
</h1>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.federation.subtitle",
"Manage external identity providers for this application.",
)}
</p>
</div>
<Button onClick={() => setCreateModalOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
{t("ui.dev.clients.federation.add_btn", "Add Provider")}
</Button>
</header>
<Card className="glass-panel">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Display Name</TableHead>
<TableHead>Provider Type</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
{t("msg.common.loading", "Loading...")}
</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell
colSpan={4}
className="h-24 text-center text-destructive"
>
{(error as Error).message}
</TableCell>
</TableRow>
) : data?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="h-24 text-center text-muted-foreground"
>
{t(
"msg.dev.clients.federation.empty",
"No IdP configurations found.",
)}
</TableCell>
</TableRow>
) : (
data?.map((config: IdpConfig) => (
<tr
key={config.id}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell className="font-medium">
{config.display_name}
</TableCell>
<TableCell>{config.provider_type.toUpperCase()}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
config.status === "active"
? "bg-blue-500 text-white"
: "bg-muted text-muted-foreground"
}`}
>
{config.status}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</tr>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<CreateIdpModal
isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)}
clientId={clientId}
/>
</div>
);
}

View File

@@ -0,0 +1,294 @@
import {
Activity,
ArrowRight,
BarChart3,
CheckCircle2,
Database,
KeyRound,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { t } from "../../lib/i18n";
const guardHighlights = [
{
titleKey: "ui.dev.dashboard.guard.policy.title",
titleFallback: "RP 정책 통제",
bodyKey: "msg.dev.dashboard.guard.policy.body",
bodyFallback:
"Relying Party 상태를 활성/비활성으로 관리하고 정책 변경을 기록합니다.",
metricKey: "ui.dev.dashboard.guard.policy.metric",
metricFallback: "Policy",
},
{
titleKey: "ui.dev.dashboard.guard.consent.title",
titleFallback: "Consent 흐름",
bodyKey: "msg.dev.dashboard.guard.consent.body",
bodyFallback:
"사용자 Consent를 조회하고 필요 시 회수해 리스크를 제어합니다.",
metricKey: "ui.dev.dashboard.guard.consent.metric",
metricFallback: "Consent",
},
{
titleKey: "ui.dev.dashboard.guard.hydra.title",
titleFallback: "Hydra Admin",
bodyKey: "msg.dev.dashboard.guard.hydra.body",
bodyFallback: "Hydra Admin API를 통해 RP 등록 현황을 동기화합니다.",
metricKey: "ui.dev.dashboard.guard.hydra.metric",
metricFallback: "Hydra",
},
];
const stackReadiness = [
{
key: "msg.dev.dashboard.stack.react",
fallback: "React 19 + Vite 7, strict TS, Router v6 data router.",
},
{
key: "msg.dev.dashboard.stack.query",
fallback: "TanStack Query 5로 RP/Consent 데이터를 캐시합니다.",
},
{
key: "msg.dev.dashboard.stack.axios",
fallback: "Axios 클라이언트에서 Bearer + 테넌트 헤더를 주입합니다.",
},
{
key: "msg.dev.dashboard.stack.tailwind",
fallback: "Tailwind + shadcn/ui로 devfront 톤을 맞춥니다.",
},
{
key: "msg.dev.dashboard.stack.proxy",
fallback: "Hydra Admin API 연동을 위한 프록시 엔드포인트 준비.",
},
];
const nextSteps = [
{
key: "msg.dev.dashboard.next.rp_workflow",
fallback: "RP 등록/수정/삭제 워크플로우 추가",
},
{
key: "msg.dev.dashboard.next.consent_filters",
fallback: "Consent 검색 필터 고도화 및 CSV 내보내기",
},
{
key: "msg.dev.dashboard.next.audit_guard",
fallback: "권한 가드 및 감사 로그 연동",
},
];
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} />
{t("ui.dev.dashboard.ready_badge", "devfront ready")}
</div>
<h2 className="text-3xl font-semibold leading-tight">
{t(
"msg.dev.dashboard.hero.title_prefix",
"RP 등록 현황과 Consent 상태를",
)}
<span className="text-[var(--color-accent)]">
{t("msg.dev.dashboard.hero.title_emphasis", " 하나의 화면")}
</span>
{t("msg.dev.dashboard.hero.title_suffix", "에서 관리합니다.")}
</h2>
<p className="text-[var(--color-muted)]">
{t(
"msg.dev.dashboard.hero.body",
"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)]">
{t("ui.dev.dashboard.badge.rp_synced", "RP registry synced")}
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
{t(
"ui.dev.dashboard.badge.consent_guard",
"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)]">
{t(
"ui.dev.dashboard.badge.policy_toggle",
"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} />
{t(
"msg.dev.dashboard.notice.dev_scope",
"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} />
{t(
"msg.dev.dashboard.notice.consent_audit",
"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} />
{t(
"msg.dev.dashboard.notice.hydra_health",
"Hydra Admin 상태 체크 준비",
)}
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{guardHighlights.map((item) => (
<div
key={item.titleKey}
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)]">
{t(item.metricKey, item.metricFallback)}
</div>
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
{t("ui.common.status.active", "active")}
</span>
</div>
<div className="relative mt-3 space-y-2">
<h3 className="text-lg font-semibold">
{t(item.titleKey, item.titleFallback)}
</h3>
<p className="text-sm text-[var(--color-muted)]">
{t(item.bodyKey, item.bodyFallback)}
</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)]">
{t("ui.dev.dashboard.stack.title", "Stack readiness")}
</p>
<h3 className="text-xl font-semibold">
{t("ui.dev.dashboard.stack.subtitle", "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)]"
>
{t("ui.dev.dashboard.stack.notes", "Setup notes")}
<ArrowRight size={14} />
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{stackReadiness.map((item) => (
<div
key={item.key}
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">{t(item.key, item.fallback)}</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)]">
{t("ui.dev.dashboard.next.title", "Next actions")}
</p>
<h3 className="mt-2 text-xl font-semibold">
{t("ui.dev.dashboard.next.subtitle", "Ship the RP controls")}
</h3>
<div className="mt-4 space-y-3">
{nextSteps.map((item, idx) => (
<div
key={item.key}
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)]">
{t(item.key, item.fallback)}
</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)]">
{t("ui.dev.dashboard.ops.title", "Ops board")}
</p>
<h3 className="text-xl font-semibold">
{t("ui.dev.dashboard.ops.subtitle", "현재 관측")}
</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">
{t("ui.dev.dashboard.ops.tag.consent", "Consent grants")}
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
{t("ui.dev.dashboard.ops.tag.rp_status", "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} />
{t("ui.dev.dashboard.ops.card.rp_requests", "RP 요청 추이")}
</div>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.pending", "준비 중")}
</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} />
{t(
"ui.dev.dashboard.ops.card.consent_revoked",
"Consent 회수 건수",
)}
</div>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.pending", "준비 중")}
</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} />
{t("ui.dev.dashboard.ops.card.hydra_status", "Hydra 상태")}
</div>
<p className="mt-3 text-2xl font-semibold">
{t("ui.common.status.ok", "정상")}
</p>
</div>
</div>
</section>
</div>
);
}
export default DashboardPage;

View File

@@ -0,0 +1,135 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree";
import type { OrgPickerTreeNode } from "./pickerTypes";
import { getOrgChartUserDisplayName } from "./userDisplay";
function getUserTenantSlug(user: UserSummary) {
return (
user.companyCode?.toLowerCase() || user.tenantSlug?.toLowerCase() || ""
);
}
function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) {
let cursor: TenantSummary | undefined = node;
const byId = new Map(allTenants.map((tenant) => [tenant.id, tenant]));
while (cursor?.parentId) {
const parent = byId.get(cursor.parentId);
if (!parent) break;
cursor = parent;
}
return cursor?.type === "COMPANY_GROUP" ? cursor.id : node.id;
}
function tenantToPickerNode(
tenant: TenantNode,
usersBySlug: Map<string, UserSummary[]>,
): OrgPickerTreeNode {
const tenantChildren = tenant.children.map((child) =>
tenantToPickerNode(child, usersBySlug),
);
const userChildren = (usersBySlug.get(tenant.slug.toLowerCase()) || []).map(
(user) => ({
type: "user" as const,
id: user.id,
name: getOrgChartUserDisplayName(user, tenant),
parentId: tenant.id,
user,
children: [],
}),
);
return {
type: "tenant",
id: tenant.id,
name: tenant.name,
parentId: tenant.parentId ?? null,
tenant,
children: [...userChildren, ...tenantChildren],
};
}
function findTenantNode(
roots: TenantNode[],
tenantId: string,
): TenantNode | undefined {
for (const root of roots) {
if (root.id === tenantId) return root;
const child = findTenantNode(root.children, tenantId);
if (child) return child;
}
return undefined;
}
export function buildOrgPickerTree({
tenants,
users,
rootTenantId,
tenantId,
}: {
tenants: TenantSummary[];
users: UserSummary[];
rootTenantId?: string;
tenantId?: string;
}) {
const usersBySlug = new Map<string, UserSummary[]>();
for (const user of users) {
if (user.status !== "active") continue;
const slug = getUserTenantSlug(user);
if (!slug) continue;
const list = usersBySlug.get(slug) || [];
list.push(user);
usersBySlug.set(slug, list);
}
const companyGroup =
tenants.find((tenant) => tenant.id === rootTenantId) ??
tenants.find((tenant) => tenant.type === "COMPANY_GROUP") ??
tenants.find((tenant) => !tenant.parentId);
if (!companyGroup) return { roots: [], companies: [], companyGroupId: "" };
const { currentBase } = buildTenantFullTree(tenants, companyGroup.id);
const groupNode =
currentBase ??
buildTenantFullTree(tenants).subTree.find(
(node) => node.id === companyGroup.id,
);
if (!groupNode) return { roots: [], companies: [], companyGroupId: "" };
const companies = groupNode.children.filter(
(node) => node.type === "COMPANY",
);
const scopedRoot = tenantId
? findTenantNode([groupNode], tenantId)
: groupNode;
const filteredRoots = scopedRoot ? [scopedRoot] : [];
const roots = filteredRoots.map((node) =>
tenantToPickerNode(node, usersBySlug),
);
return {
roots,
companies: companies.map((company) => ({
id: company.id,
name: company.name,
companyGroupTenantId: getCompanyGroupId(company, tenants),
})),
companyGroupId: companyGroup.id,
};
}
export function flattenDescendants(node: OrgPickerTreeNode) {
const descendants: OrgPickerTreeNode[] = [];
const walk = (current: OrgPickerTreeNode) => {
for (const child of current.children) {
descendants.push(child);
walk(child);
}
};
walk(node);
return descendants;
}

View File

@@ -0,0 +1,98 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
export type OrgPickerMode = "single" | "multiple";
export type OrgPickerSelectableType = "tenant" | "user" | "both";
export type OrgPickerObjectType = "tenant" | "user";
export type OrgPickerSelection = {
type: OrgPickerObjectType;
id: string;
name: string;
};
export type OrgPickerResult = {
mode: OrgPickerMode;
selections: OrgPickerSelection[];
};
export type OrgPickerEmbedOptions = {
mode: OrgPickerMode;
select: OrgPickerSelectableType;
includeDescendants: boolean;
showDescendantToggle: boolean;
tenantId: string;
width: number;
height: number;
};
export type OrgPickerTreeNode = {
type: OrgPickerObjectType;
id: string;
name: string;
parentId: string | null;
tenant?: TenantSummary;
user?: UserSummary;
children: OrgPickerTreeNode[];
};
export function nodeKey(node: Pick<OrgPickerTreeNode, "type" | "id">) {
return `${node.type}:${node.id}`;
}
export function selectionKey(selection: OrgPickerSelection) {
return `${selection.type}:${selection.id}`;
}
export function parseOrgPickerMode(value: string | null): OrgPickerMode {
return value === "multiple" ? "multiple" : "single";
}
export function parseOrgPickerSelectableType(
value: string | null,
): OrgPickerSelectableType {
if (value === "tenant" || value === "user") return value;
return "both";
}
function parseEmbedDimension(value: string | null, fallback: number) {
const parsed = Number.parseInt(value ?? "", 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(1600, Math.max(240, parsed));
}
export function parseOrgPickerEmbedOptions(search: string) {
const params = new URLSearchParams(search);
return {
mode:
params.get("mode") === "single"
? ("single" as const)
: ("multiple" as const),
select: parseOrgPickerSelectableType(params.get("select")),
includeDescendants: params.get("includeDescendants") !== "false",
showDescendantToggle: params.get("showDescendantToggle") !== "false",
tenantId: params.get("tenantId") ?? params.get("companyTenantId") ?? "",
width: parseEmbedDimension(params.get("width"), 400),
height: parseEmbedDimension(params.get("height"), 600),
};
}
export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) {
const params = new URLSearchParams({
mode: options.mode,
select: options.select,
width: String(options.width),
height: String(options.height),
});
const tenantId = options.tenantId.trim();
if (tenantId) {
params.set("tenantId", tenantId);
}
if (options.mode === "multiple") {
params.set("includeDescendants", String(options.includeDescendants));
params.set("showDescendantToggle", String(options.showDescendantToggle));
}
return `/embed/picker?${params.toString()}`;
}

View File

@@ -0,0 +1,881 @@
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { useLocation, useParams } from "react-router-dom";
import {
type TenantSummary,
type UserSummary,
fetchPublicOrgChart,
fetchTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
type OrgNode = {
id: string;
name: string;
level: number;
members: UserSummary[];
children: OrgNode[];
totalCount: number;
totalMemberIds: Set<string>;
companyCode?: string;
type?: string;
};
type ViewBox = {
x: number;
y: number;
width: number;
height: number;
};
type VisualNode = {
node: OrgNode;
x: number;
y: number;
width: number;
height: number;
members: UserSummary[];
collapsed: boolean;
};
type VisualEdge = {
key: string;
path: string;
};
type ChartLayout = {
nodes: VisualNode[];
edges: VisualEdge[];
width: number;
height: number;
};
const NODE_WIDTH = 340;
const HEADER_HEIGHT = 42;
const MEMBER_ROW_HEIGHT = 24;
const NODE_PADDING_Y = 12;
const ROOT_GAP_X = 120;
const CHILD_GAP_X = 80;
const CHILD_GAP_Y = 96;
const CHART_MARGIN = 72;
const MIN_SCALE = 0.45;
const MAX_SCALE = 2.4;
const ZOOM_SENSITIVITY = 0.0015;
const FAMILY_FILTER_ID = "hanmac-family";
const ROLE_ORDER = [
"사장",
"부사장",
"전무",
"상무",
"이사",
"수석",
"책임",
"선임",
"주임",
"사원",
];
function getRankWeight(
user: UserSummary,
tenant?: { id: string; slug: string },
) {
const profile = getUserOrgProfile(user, tenant);
const role = profile.position || "";
const order = ROLE_ORDER.indexOf(role);
const isLeader =
profile.position.endsWith("장") || profile.jobTitle.endsWith("장");
return (isLeader ? -100 : 0) + (order === -1 ? 99 : order);
}
function getNodeHeight(members: UserSummary[]) {
return (
HEADER_HEIGHT +
NODE_PADDING_Y * 2 +
Math.max(members.length, 1) * MEMBER_ROW_HEIGHT
);
}
function clampScale(scale: number) {
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
}
function buildOrgNode(
tenantNode: TenantNode,
usersMap: Map<string, UserSummary[]>,
depth: number,
): OrgNode {
const slug = tenantNode.slug.toLowerCase();
const members = usersMap.get(slug) || [];
const children = tenantNode.children.map((child) =>
buildOrgNode(child, usersMap, depth + 1),
);
const totalMemberIds = new Set(members.map((member) => member.id));
for (const child of children) {
for (const memberId of child.totalMemberIds) {
totalMemberIds.add(memberId);
}
}
return {
id: tenantNode.id,
name: tenantNode.name,
level: depth,
members,
children,
totalCount: totalMemberIds.size,
totalMemberIds,
companyCode: slug,
type: tenantNode.type,
};
}
function layoutTree(
node: OrgNode,
collapsedIds: Set<string>,
originX = 0,
originY = 0,
): ChartLayout {
const members = [...node.members].sort(
(a, b) =>
getRankWeight(a, { id: node.id, slug: node.companyCode ?? "" }) -
getRankWeight(b, { id: node.id, slug: node.companyCode ?? "" }),
);
const nodeHeight = getNodeHeight(members);
const collapsed = collapsedIds.has(node.id);
const childLayouts = collapsed
? []
: node.children.map((child) => layoutTree(child, collapsedIds));
const childrenWidth =
childLayouts.length > 0
? childLayouts.reduce((sum, child) => sum + child.width, 0) +
CHILD_GAP_X * (childLayouts.length - 1)
: 0;
const subtreeWidth = Math.max(NODE_WIDTH, childrenWidth);
const nodeX = originX + (subtreeWidth - NODE_WIDTH) / 2;
const nodeY = originY;
const visualNode: VisualNode = {
node,
x: nodeX,
y: nodeY,
width: NODE_WIDTH,
height: nodeHeight,
members,
collapsed,
};
let cursorX = originX;
let maxChildHeight = 0;
const nodes: VisualNode[] = [visualNode];
const edges: VisualEdge[] = [];
for (const childLayout of childLayouts) {
const childOffsetY = originY + nodeHeight + CHILD_GAP_Y;
const shiftedNodes = childLayout.nodes.map((childNode) => ({
...childNode,
x: childNode.x + cursorX,
y: childNode.y + childOffsetY,
}));
const shiftedEdges = childLayout.edges.map((edge) => ({
...edge,
path: offsetPath(edge.path, cursorX, childOffsetY),
}));
const childRoot = shiftedNodes[0];
const parentCenterX = nodeX + NODE_WIDTH / 2;
const parentBottomY = nodeY + nodeHeight;
const childCenterX = childRoot.x + childRoot.width / 2;
const childTopY = childRoot.y;
const midY = parentBottomY + CHILD_GAP_Y / 2;
nodes.push(...shiftedNodes);
edges.push({
key: `${node.id}->${childRoot.node.id}`,
path: `M ${parentCenterX} ${parentBottomY} L ${parentCenterX} ${midY} L ${childCenterX} ${midY} L ${childCenterX} ${childTopY}`,
});
edges.push(...shiftedEdges);
cursorX += childLayout.width + CHILD_GAP_X;
maxChildHeight = Math.max(maxChildHeight, childLayout.height);
}
const height =
childLayouts.length > 0
? nodeHeight + CHILD_GAP_Y + maxChildHeight
: nodeHeight;
return {
nodes,
edges,
width: subtreeWidth,
height,
};
}
function offsetPath(path: string, offsetX: number, offsetY: number) {
const numbers = path.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
let index = 0;
return path.replace(/-?\d+(\.\d+)?/g, () => {
const value = numbers[index] ?? 0;
const nextValue = index % 2 === 0 ? value + offsetX : value + offsetY;
index += 1;
return String(nextValue);
});
}
function layoutForest(
nodes: OrgNode[],
collapsedIds: Set<string>,
): ChartLayout {
const layouts = nodes.map((node) => layoutTree(node, collapsedIds));
const contentWidth =
layouts.reduce((sum, layout) => sum + layout.width, 0) +
ROOT_GAP_X * Math.max(layouts.length - 1, 0);
const contentHeight = Math.max(1, ...layouts.map((layout) => layout.height));
const visualNodes: VisualNode[] = [];
const edges: VisualEdge[] = [];
let cursorX = CHART_MARGIN;
for (const layout of layouts) {
visualNodes.push(
...layout.nodes.map((node) => ({
...node,
x: node.x + cursorX,
y: node.y + CHART_MARGIN,
})),
);
edges.push(
...layout.edges.map((edge) => ({
...edge,
path: offsetPath(edge.path, cursorX, CHART_MARGIN),
})),
);
cursorX += layout.width + ROOT_GAP_X;
}
return {
nodes: visualNodes,
edges,
width: Math.max(contentWidth + CHART_MARGIN * 2, 960),
height: Math.max(contentHeight + CHART_MARGIN * 2, 640),
};
}
function makeInitialViewBox(
layout: ChartLayout,
viewport: DOMRect | null,
): ViewBox {
const aspect =
viewport && viewport.height > 0 ? viewport.width / viewport.height : 16 / 9;
const width = Math.max(layout.width, 960);
const height = Math.max(width / aspect, layout.height);
return {
x: 0,
y: 0,
width,
height,
};
}
function getViewBoxScale(layout: ChartLayout, viewBox: ViewBox) {
return layout.width / viewBox.width;
}
function getColorForCompany(companyCode?: string) {
const code = (companyCode || "").toLowerCase();
if (code.includes("hanmac")) return "#ef4444";
if (code.includes("saman")) return "#ffb366";
if (code.includes("ptc")) return "#a855f7";
if (code.includes("baron")) return "#3b82f6";
return "#64748b";
}
function isVisibleOrgChartUser(user: UserSummary) {
return (
!user.email.toLowerCase().endsWith("@hanmac.kr") &&
!isSystemGlobalUser(user)
);
}
function isSystemGlobalTenant(
tenant?: Pick<TenantSummary, "id" | "slug" | "type" | "name">,
) {
if (!tenant) return false;
const values = [tenant.id, tenant.slug, tenant.type, tenant.name].map(
(value) => value.toLowerCase().replaceAll("_", "-"),
);
return values.some(
(value) =>
value === "system" ||
value === "global" ||
value === "system-global" ||
value === "tenant-global" ||
value === "시스템 전역",
);
}
function isSystemGlobalUser(user: UserSummary) {
const normalizedRole = user.role.toLowerCase().replaceAll("_", "-");
return (
normalizedRole === "super-admin" ||
normalizedRole === "superadmin" ||
normalizedRole === "system-admin" ||
isSystemGlobalTenant(user.tenant) ||
isSystemGlobalTenant({
id: user.companyCode || user.tenantSlug || "",
slug: user.companyCode || user.tenantSlug || "",
type: user.role,
name: user.role,
})
);
}
function findNodeByTenantId(
nodes: TenantNode[],
tenantId: string,
): TenantNode | null {
for (const node of nodes) {
if (node.id === tenantId) return node;
const child = findNodeByTenantId(node.children, tenantId);
if (child) return child;
}
return null;
}
function filterSystemGlobalTenants(tenants: TenantSummary[]) {
const excludedIds = new Set(
tenants.filter(isSystemGlobalTenant).map((tenant) => tenant.id),
);
let changed = true;
while (changed) {
changed = false;
for (const tenant of tenants) {
if (
tenant.parentId &&
excludedIds.has(tenant.parentId) &&
!excludedIds.has(tenant.id)
) {
excludedIds.add(tenant.id);
changed = true;
}
}
}
return tenants.filter((tenant) => !excludedIds.has(tenant.id));
}
type TenantIndexes = {
byId: Map<string, TenantNode>;
bySlug: Map<string, TenantNode>;
};
function buildTenantIndexes(nodes: TenantNode[]): TenantIndexes {
const byId = new Map<string, TenantNode>();
const bySlug = new Map<string, TenantNode>();
const visit = (node: TenantNode) => {
byId.set(node.id, node);
bySlug.set(node.slug.toLowerCase(), node);
for (const child of node.children) visit(child);
};
for (const node of nodes) visit(node);
return { byId, bySlug };
}
function isDescendantTenant(
candidate: TenantNode,
ancestor: TenantNode,
byId: Map<string, TenantNode>,
) {
const visited = new Set<string>();
let currentParentId = candidate.parentId;
while (currentParentId) {
if (currentParentId === ancestor.id) return true;
if (visited.has(currentParentId)) return false;
visited.add(currentParentId);
currentParentId = byId.get(currentParentId)?.parentId;
}
return false;
}
function getLeafMembershipSlugs(
slugs: Set<string>,
tenantIndexes: TenantIndexes,
) {
const memberships = Array.from(slugs);
return memberships.filter((slug) => {
const tenant = tenantIndexes.bySlug.get(slug);
if (!tenant) return true;
return !memberships.some((otherSlug) => {
if (otherSlug === slug) return false;
const otherTenant = tenantIndexes.bySlug.get(otherSlug);
if (!otherTenant) return false;
return isDescendantTenant(otherTenant, tenant, tenantIndexes.byId);
});
});
}
function buildUsersMap(
users: UserSummary[],
rootNodes: TenantNode[],
options: { activeOnly: boolean },
) {
const tenantIndexes = buildTenantIndexes(rootNodes);
const map = new Map<string, UserSummary[]>();
for (const user of users) {
if (options.activeOnly && user.status !== "active") continue;
if (!isVisibleOrgChartUser(user)) continue;
const slugs = new Set<string>();
const primarySlug =
user.companyCode?.toLowerCase() || user.tenantSlug?.toLowerCase() || "";
if (
primarySlug &&
!isSystemGlobalTenant({
id: primarySlug,
slug: primarySlug,
type: primarySlug,
name: primarySlug,
})
) {
slugs.add(primarySlug);
}
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
slugs.add(user.tenant.slug.toLowerCase());
}
for (const joinedTenant of user.joinedTenants || []) {
if (joinedTenant.slug && !isSystemGlobalTenant(joinedTenant)) {
slugs.add(joinedTenant.slug.toLowerCase());
}
}
for (const slug of getLeafMembershipSlugs(slugs, tenantIndexes)) {
const list = map.get(slug) || [];
if (!list.some((existing) => existing.id === user.id)) list.push(user);
map.set(slug, list);
}
}
return map;
}
export function TenantOrgChartPage() {
const viewportRef = React.useRef<HTMLDivElement>(null);
const dragRef = React.useRef<{
pointerId: number;
startX: number;
startY: number;
startViewBox: ViewBox;
} | null>(null);
const { tenantId } = useParams<{ tenantId?: string }>();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const shareToken = searchParams.get("token");
const [selectedTenantFilter, setSelectedTenantFilter] =
React.useState(FAMILY_FILTER_ID);
const [collapsedIds, setCollapsedIds] = React.useState<Set<string>>(
() => new Set(),
);
const [viewBox, setViewBox] = React.useState<ViewBox>({
x: 0,
y: 0,
width: 1280,
height: 720,
});
const [hasUserMovedCanvas, setHasUserMovedCanvas] = React.useState(false);
const [isDragging, setIsDragging] = React.useState(false);
const publicQuery = useQuery({
queryKey: ["public-orgchart", shareToken],
queryFn: () => {
if (!shareToken) throw new Error("Missing share token");
return fetchPublicOrgChart(shareToken);
},
enabled: !!shareToken,
});
const tenantsQuery = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(10000, 0),
enabled: !shareToken,
});
const usersQuery = useQuery({
queryKey: ["users", { limit: 5000, offset: 0 }],
queryFn: () => fetchUsers(5000, 0),
enabled: !shareToken,
});
const { rootNodes, usersMap, sharedWith } = React.useMemo(() => {
if (shareToken) {
if (!publicQuery.data) {
return {
rootNodes: [],
usersMap: new Map<string, UserSummary[]>(),
sharedWith: "",
};
}
const rootNodes = buildTenantFullTree(
filterSystemGlobalTenants(publicQuery.data.tenants),
).subTree;
return {
rootNodes,
usersMap: buildUsersMap(publicQuery.data.users, rootNodes, {
activeOnly: false,
}),
sharedWith: publicQuery.data.sharedWith,
};
}
if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
return {
rootNodes: [],
usersMap: new Map<string, UserSummary[]>(),
sharedWith: "",
};
}
const rootNodes = buildTenantFullTree(
filterSystemGlobalTenants(tenantsQuery.data.items),
).subTree;
return {
rootNodes,
usersMap: buildUsersMap(usersQuery.data.items, rootNodes, {
activeOnly: true,
}),
sharedWith: "",
};
}, [publicQuery.data, shareToken, tenantsQuery.data, usersQuery.data]);
const familyRoot = React.useMemo(() => {
return (
rootNodes.find((node) => node.type === "COMPANY_GROUP") ??
rootNodes[0] ??
null
);
}, [rootNodes]);
const companyFilters = React.useMemo(() => {
return (familyRoot?.children ?? [])
.filter((node) => node.type === "COMPANY")
.map((node) => ({ id: node.id, label: node.name }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [familyRoot]);
const filterOptions = React.useMemo(
() => [{ id: FAMILY_FILTER_ID, label: "한맥가족" }, ...companyFilters],
[companyFilters],
);
React.useEffect(() => {
if (!tenantId) return;
const searchRoots = familyRoot ? [familyRoot] : rootNodes;
const match = searchRoots
.flatMap((node) => [node, ...node.children])
.find(
(node) =>
node.id === tenantId ||
node.slug.toLowerCase() === tenantId.toLowerCase() ||
node.name === tenantId,
);
if (match?.type === "COMPANY") setSelectedTenantFilter(match.id);
}, [familyRoot, rootNodes, tenantId]);
const targetNodes = React.useMemo(() => {
if (!familyRoot) return [];
if (selectedTenantFilter === FAMILY_FILTER_ID) {
return [buildOrgNode(familyRoot, usersMap, 0)];
}
const companyNode = findNodeByTenantId([familyRoot], selectedTenantFilter);
return companyNode ? [buildOrgNode(companyNode, usersMap, 0)] : [];
}, [familyRoot, selectedTenantFilter, usersMap]);
const layout = React.useMemo(
() => layoutForest(targetNodes, collapsedIds),
[collapsedIds, targetNodes],
);
React.useLayoutEffect(() => {
if (hasUserMovedCanvas) return;
setViewBox(
makeInitialViewBox(
layout,
viewportRef.current?.getBoundingClientRect() ?? null,
),
);
}, [hasUserMovedCanvas, layout]);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startViewBox: viewBox,
};
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = dragRef.current;
const rect = viewportRef.current?.getBoundingClientRect();
if (!dragState || !rect || dragState.pointerId !== event.pointerId) return;
const dx =
((event.clientX - dragState.startX) / rect.width) *
dragState.startViewBox.width;
const dy =
((event.clientY - dragState.startY) / rect.height) *
dragState.startViewBox.height;
setHasUserMovedCanvas(true);
setViewBox({
...dragState.startViewBox,
x: dragState.startViewBox.x - dx,
y: dragState.startViewBox.y - dy,
});
};
const finishDrag = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = dragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragRef.current = null;
setIsDragging(false);
};
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
const currentScale = getViewBoxScale(layout, viewBox);
const nextScale = clampScale(
currentScale * Math.exp(-event.deltaY * ZOOM_SENSITIVITY),
);
const nextWidth = layout.width / nextScale;
const nextHeight = nextWidth / (rect.width / rect.height);
const pointX =
viewBox.x + ((event.clientX - rect.left) / rect.width) * viewBox.width;
const pointY =
viewBox.y + ((event.clientY - rect.top) / rect.height) * viewBox.height;
const ratioX = (pointX - viewBox.x) / viewBox.width;
const ratioY = (pointY - viewBox.y) / viewBox.height;
setHasUserMovedCanvas(true);
setViewBox({
x: pointX - nextWidth * ratioX,
y: pointY - nextHeight * ratioY,
width: nextWidth,
height: nextHeight,
});
};
const isLoading = shareToken
? publicQuery.isLoading
: tenantsQuery.isLoading || usersQuery.isLoading;
const isError = shareToken
? publicQuery.isError
: tenantsQuery.isError || usersQuery.isError;
const totalUsers = React.useMemo(() => {
const ids = new Set<string>();
for (const node of targetNodes) {
for (const memberId of node.totalMemberIds) {
ids.add(memberId);
}
}
return ids.size;
}, [targetNodes]);
if (isLoading) {
return (
<div className="p-8 text-center text-muted-foreground"> ...</div>
);
}
if (isError) {
return (
<div className="p-8 text-center text-red-500">
.
</div>
);
}
return (
<div className="flex h-[calc(100vh-theme(spacing.32))] flex-col overflow-hidden rounded-xl border border-[#e0d5c1] bg-[#f6efe6] shadow-sm">
<header className="z-10 flex shrink-0 flex-col items-start justify-between border-b border-[#f2c484]/30 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] px-6 py-4 sm:flex-row sm:items-center">
<div className="mb-4 flex flex-col gap-1 sm:mb-0">
<p className="text-xs font-bold uppercase tracking-wider text-[#f2c484]">
{shareToken ? `공유된 조직도: ${sharedWith}` : "MH Dashboard"}
</p>
<h2 className="text-xl font-black text-[#f7f0e4]"> </h2>
</div>
<div className="custom-scrollbar flex max-w-full items-center gap-2 overflow-x-auto">
{filterOptions.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedTenantFilter(option.id);
setCollapsedIds(new Set());
setHasUserMovedCanvas(false);
}}
className={`whitespace-nowrap rounded-full border px-4 py-2 text-xs font-bold transition-all ${
selectedTenantFilter === option.id
? "border-[#f2c484]/40 bg-[linear-gradient(180deg,rgba(255,253,248,0.98),rgba(245,235,221,0.94))] text-[#0a2a22] shadow-sm"
: "border-[#f2c484]/30 bg-white/10 text-[#f7f0e4]/70 hover:border-[#f2c484]/50 hover:text-[#f7f0e4]"
}`}
>
{option.label}
</button>
))}
<div className="ml-2 whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-[#f2c484]/10 px-4 py-2 text-xs font-black text-[#f2c484] shadow-sm">
{totalUsers}
</div>
</div>
</header>
<div
className={`relative flex-1 touch-none select-none overflow-hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
data-testid="orgchart-viewport"
onPointerCancel={finishDrag}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onWheel={handleWheel}
ref={viewportRef}
style={{
background:
"radial-gradient(circle at top left, rgba(214, 138, 58, 0.08), transparent 24%), radial-gradient(circle at top right, rgba(47, 153, 115, 0.05), transparent 20%), linear-gradient(180deg, rgba(246, 239, 230, 0.98), rgba(241, 234, 223, 0.96))",
}}
>
<svg
className="h-full w-full"
data-scale={getViewBoxScale(layout, viewBox).toFixed(3)}
data-testid="orgchart-vector-svg"
role="img"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
>
<title> </title>
<g data-testid="orgchart-canvas">
{layout.edges.map((edge) => (
<path
d={edge.path}
fill="none"
key={edge.key}
stroke="#bca58a"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
))}
{layout.nodes.map((visualNode) => (
<SvgOrgNode key={visualNode.node.id} visualNode={visualNode} />
))}
</g>
</svg>
</div>
</div>
);
}
function SvgOrgNode({
visualNode,
}: {
visualNode: VisualNode;
}) {
const { node, x, y, width, height, members, collapsed } = visualNode;
const headerFill = node.level === 0 ? "#0a2a22" : "#2f5547";
const accent = getColorForCompany(node.companyCode);
return (
<g transform={`translate(${x} ${y})`}>
<rect
fill="#ffffff"
height={height}
rx="10"
stroke="#e0d5c1"
strokeWidth="1.5"
width={width}
/>
<rect fill={headerFill} height={HEADER_HEIGHT} rx="10" width={width} />
<rect
fill={headerFill}
height="18"
width={width}
y={HEADER_HEIGHT - 18}
/>
<text
fill="#f7f0e4"
fontSize={node.level === 0 ? 17 : 15}
fontWeight="800"
textAnchor="middle"
x={width / 2}
y="27"
>
{node.name}
</text>
<g>
<rect
fill="rgba(0,0,0,0.22)"
height="22"
rx="11"
width="52"
x={width - 66}
y="10"
/>
<text
fill="#ffffff"
fontSize="12"
fontWeight="700"
textAnchor="middle"
x={width - 40}
y="25"
>
{collapsed ? "+" : node.totalCount}
</text>
</g>
{members.length > 0 ? (
members.map((member, index) => (
<g
key={member.id}
transform={`translate(14 ${HEADER_HEIGHT + NODE_PADDING_Y + index * MEMBER_ROW_HEIGHT})`}
>
<rect
fill="#ffffff"
height="20"
rx="4"
stroke="#e5e7eb"
width={width - 28}
/>
<rect fill={accent} height="20" rx="4" width="4" />
<text fill="#334155" fontSize="12" fontWeight="800" x="12" y="14">
{getOrgChartUserDisplayName(member, {
id: node.id,
slug: node.companyCode ?? "",
})}
</text>
</g>
))
) : (
<text fill="#94a3b8" fontSize="12" x="16" y={HEADER_HEIGHT + 28}>
</text>
)}
</g>
);
}

View File

@@ -0,0 +1,48 @@
import { GitBranch, Network, PanelTop } from "lucide-react";
import { NavLink, Outlet } from "react-router-dom";
const navItems = [
{ to: "/chart", label: "조직도", icon: Network },
{ to: "/picker", label: "조직 선택기", icon: GitBranch },
{ to: "/embed-preview", label: "임베딩 검증", icon: PanelTop },
];
export function OrgFrontLayout() {
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Baron Orgfront
</p>
<h1 className="text-xl font-semibold"> </h1>
</div>
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground",
].join(" ")
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-5">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,218 @@
import * as React from "react";
import { useLocation } from "react-router-dom";
import {
type OrgPickerEmbedOptions,
type OrgPickerMode,
type OrgPickerSelectableType,
buildOrgPickerEmbedSrc,
parseOrgPickerEmbedOptions,
} from "../pickerTypes";
type PickerMessage = {
type: string;
payload?: unknown;
error?: string;
};
function PickerScenarioControls({
options,
onChange,
}: {
options: OrgPickerEmbedOptions;
onChange: (options: OrgPickerEmbedOptions) => void;
}) {
return (
<div className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto] lg:items-end">
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.mode}
onChange={(event) =>
onChange({
...options,
mode: event.target.value as OrgPickerMode,
})
}
>
<option value="multiple"> </option>
<option value="single"> </option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.select}
onChange={(event) =>
onChange({
...options,
select: event.target.value as OrgPickerSelectableType,
})
}
>
<option value="both">&</option>
<option value="tenant"></option>
<option value="user"></option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant ID</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
onChange({
...options,
tenantId: event.target.value,
})
}
placeholder="company-baron"
type="text"
value={options.tenantId}
/>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.includeDescendants}
disabled={options.mode === "single"}
onChange={(event) =>
onChange({
...options,
includeDescendants: event.target.checked,
})
}
type="checkbox"
/>
<span> </span>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.showDescendantToggle}
disabled={options.mode === "single"}
onChange={(event) =>
onChange({
...options,
showDescendantToggle: event.target.checked,
})
}
type="checkbox"
/>
<span> </span>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
min={240}
max={1600}
onChange={(event) =>
onChange({
...options,
width: Number.parseInt(event.target.value || "400", 10),
})
}
type="number"
value={options.width}
/>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
min={240}
max={1600}
onChange={(event) =>
onChange({
...options,
height: Number.parseInt(event.target.value || "600", 10),
})
}
type="number"
value={options.height}
/>
</label>
</div>
);
}
export function OrgPickerEmbedPreviewPage() {
const location = useLocation();
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const [lastMessage, setLastMessage] = React.useState<PickerMessage | null>(
null,
);
const pickerSrc = buildOrgPickerEmbedSrc(options);
React.useEffect(() => {
const handleMessage = (event: MessageEvent<PickerMessage>) => {
if (!event.data?.type?.startsWith("orgfront:picker:")) return;
setLastMessage(event.data);
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
return (
<div className="space-y-5">
<header className="space-y-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Embed Preview
</p>
<h1 className="text-2xl font-semibold"> </h1>
</div>
<div
className="min-h-16 w-full overflow-x-auto rounded-md border border-border bg-card px-4 py-3 font-mono text-sm leading-6 text-foreground"
data-testid="embed-preview-src"
>
{pickerSrc}
</div>
</header>
<PickerScenarioControls options={options} onChange={setOptions} />
<section className="grid gap-4 lg:grid-cols-[1fr,360px]">
<div
className="max-w-full resize overflow-auto rounded-md border border-border bg-card"
data-testid="embed-preview-frame-shell"
style={{
width: options.width,
height: options.height,
}}
>
<iframe
className="h-full w-full bg-background"
src={pickerSrc}
title="조직 선택기 임베딩 검증"
/>
</div>
<aside className="space-y-3 rounded-md border border-border bg-card p-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
postMessage
</p>
<h2 className="text-lg font-semibold"> </h2>
</div>
<pre
className="min-h-[280px] overflow-auto rounded-md border border-border bg-background p-3 text-xs"
data-testid="embed-preview-output"
>
{lastMessage
? JSON.stringify(lastMessage, null, 2)
: "아직 수신된 메시지가 없습니다."}
</pre>
</aside>
</section>
</div>
);
}

View File

@@ -0,0 +1,709 @@
import { useQuery } from "@tanstack/react-query";
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import * as React from "react";
import { useLocation } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchTenants, fetchUsers } from "../../../lib/adminApi";
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
import {
type OrgPickerEmbedOptions,
type OrgPickerMode,
type OrgPickerResult,
type OrgPickerSelectableType,
type OrgPickerSelection,
type OrgPickerTreeNode,
buildOrgPickerEmbedSrc,
nodeKey,
parseOrgPickerEmbedOptions,
parseOrgPickerMode,
parseOrgPickerSelectableType,
} from "../pickerTypes";
function canSelectNode(
node: OrgPickerTreeNode,
select: OrgPickerSelectableType,
) {
return select === "both" || select === node.type;
}
function toSelection(node: OrgPickerTreeNode): OrgPickerSelection {
return {
type: node.type,
id: node.id,
name: node.name,
};
}
function collectSelectedNodes({
roots,
selectedKeys,
includeDescendants,
select,
}: {
roots: OrgPickerTreeNode[];
selectedKeys: Set<string>;
includeDescendants: boolean;
select: OrgPickerSelectableType;
}) {
const selected = new Map<string, OrgPickerTreeNode>();
const visit = (node: OrgPickerTreeNode) => {
const key = nodeKey(node);
if (selectedKeys.has(key) && canSelectNode(node, select)) {
selected.set(key, node);
if (includeDescendants && node.type === "tenant") {
for (const descendant of flattenDescendants(node)) {
if (canSelectNode(descendant, select)) {
selected.set(nodeKey(descendant), descendant);
}
}
}
}
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return Array.from(selected.values()).map(toSelection);
}
function collectCheckedKeys({
roots,
selectedKeys,
includeDescendants,
select,
}: {
roots: OrgPickerTreeNode[];
selectedKeys: Set<string>;
includeDescendants: boolean;
select: OrgPickerSelectableType;
}) {
const checkedKeys = new Set(selectedKeys);
if (!includeDescendants) return checkedKeys;
const visit = (node: OrgPickerTreeNode) => {
const key = nodeKey(node);
if (selectedKeys.has(key) && node.type === "tenant") {
for (const descendant of flattenDescendants(node)) {
if (canSelectNode(descendant, select)) {
checkedKeys.add(nodeKey(descendant));
}
}
}
for (const child of node.children) visit(child);
};
for (const root of roots) visit(root);
return checkedKeys;
}
function postPickerMessage(message: unknown) {
window.parent.postMessage(message, "*");
}
function collectSearchValues(value: unknown, depth = 0): string[] {
if (value == null || depth > 4) return [];
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return [String(value)];
}
if (Array.isArray(value)) {
return value.flatMap((item) => collectSearchValues(item, depth + 1));
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>).flatMap(
([key, item]) => [key, ...collectSearchValues(item, depth + 1)],
);
}
return [];
}
function getNodeSearchValues(node: OrgPickerTreeNode) {
const tenantSearchValues = node.tenant
? collectSearchValues({
id: node.tenant.id,
type: node.tenant.type,
name: node.tenant.name,
slug: node.tenant.slug,
description: node.tenant.description,
status: node.tenant.status,
domains: node.tenant.domains,
parentId: node.tenant.parentId,
config: node.tenant.config,
memberCount: node.tenant.memberCount,
createdAt: node.tenant.createdAt,
updatedAt: node.tenant.updatedAt,
})
: [];
return [
node.type,
node.id,
node.name,
node.parentId ?? "",
...tenantSearchValues,
...collectSearchValues(node.user),
].map((value) => value.toLowerCase());
}
function nodeMatchesSearch(node: OrgPickerTreeNode, query: string) {
return getNodeSearchValues(node).some((value) => value.includes(query));
}
function filterPickerTree(
roots: OrgPickerTreeNode[],
rawQuery: string,
): OrgPickerTreeNode[] {
const query = rawQuery.trim().toLowerCase();
if (!query) return roots;
const filterNode = (node: OrgPickerTreeNode): OrgPickerTreeNode | null => {
const filteredChildren = node.children
.map(filterNode)
.filter((child): child is OrgPickerTreeNode => Boolean(child));
if (nodeMatchesSearch(node, query) || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren,
};
}
return null;
};
return roots
.map(filterNode)
.filter((node): node is OrgPickerTreeNode => Boolean(node));
}
function OrgPickerTree({
roots,
mode,
select,
selectedKeys,
onSingleSelect,
onToggle,
}: {
roots: OrgPickerTreeNode[];
mode: OrgPickerMode;
select: OrgPickerSelectableType;
selectedKeys: Set<string>;
onSingleSelect: (node: OrgPickerTreeNode) => void;
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
}) {
return (
<div className="space-y-1" data-testid="org-picker-tree">
{roots.map((node) => (
<OrgPickerTreeItem
key={nodeKey(node)}
mode={mode}
node={node}
onSingleSelect={onSingleSelect}
onToggle={onToggle}
select={select}
selectedKeys={selectedKeys}
/>
))}
</div>
);
}
function OrgPickerTreeItem({
node,
mode,
select,
selectedKeys,
onSingleSelect,
onToggle,
depth = 0,
}: {
node: OrgPickerTreeNode;
mode: OrgPickerMode;
select: OrgPickerSelectableType;
selectedKeys: Set<string>;
onSingleSelect: (node: OrgPickerTreeNode) => void;
onToggle: (node: OrgPickerTreeNode, checked: boolean) => void;
depth?: number;
}) {
const [isOpen, setIsOpen] = React.useState(true);
const selectable = canSelectNode(node, select);
const hasChildren = node.children.length > 0;
const key = nodeKey(node);
const checked = selectedKeys.has(key);
const label = `${node.name} 선택`;
const email = node.type === "user" ? node.user?.email : undefined;
const nameTestId =
node.type === "tenant"
? "org-picker-node-name-tenant"
: "org-picker-node-name-user";
const content = (
<span className="flex min-w-0 flex-col">
<span
className={`truncate font-semibold leading-5 ${
node.type === "tenant" ? "text-[#0a2114]" : ""
}`}
data-testid={nameTestId}
>
{node.name}
</span>
{email ? (
<span className="truncate text-xs leading-5 text-muted-foreground">
{email}
</span>
) : null}
</span>
);
return (
<div className="relative">
<div
className={`group flex min-h-7 items-center gap-1.5 rounded-sm py-0.5 pr-1.5 transition ${
mode === "single" && checked
? "bg-primary/15 text-foreground ring-2 ring-primary/60 shadow-sm"
: "hover:bg-secondary/50"
} ${depth > 0 ? "pl-4" : "pl-1"}`}
data-selected={mode === "single" && checked ? "true" : undefined}
>
{hasChildren ? (
<button
type="button"
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted-foreground transition hover:bg-secondary"
onClick={() => setIsOpen((current) => !current)}
aria-label={`${node.name} ${isOpen ? "접기" : "펼치기"}`}
>
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : (
<span className="h-6 w-6 shrink-0" aria-hidden="true" />
)}
{mode === "multiple" && selectable ? (
<input
aria-label={label}
checked={checked}
className="h-3.5 w-3.5 rounded border-border"
onChange={(event) => onToggle(node, event.target.checked)}
type="checkbox"
/>
) : null}
{mode === "single" && selectable ? (
<button
type="button"
aria-pressed={checked}
className={`min-w-0 flex-1 rounded-sm px-1 text-left outline-none transition focus-visible:ring-2 focus-visible:ring-ring ${
checked ? "text-primary" : ""
}`}
data-selected={checked ? "true" : undefined}
onClick={() => onSingleSelect(node)}
>
{content}
</button>
) : (
<div className="min-w-0 flex-1">{content}</div>
)}
</div>
{isOpen && hasChildren ? (
<div className="ml-4">
{node.children.map((child) => (
<OrgPickerTreeItem
depth={depth + 1}
key={nodeKey(child)}
mode={mode}
node={child}
onSingleSelect={onSingleSelect}
onToggle={onToggle}
select={select}
selectedKeys={selectedKeys}
/>
))}
</div>
) : null}
</div>
);
}
export function OrgPickerEmbedPage() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const mode = parseOrgPickerMode(searchParams.get("mode"));
const select = parseOrgPickerSelectableType(searchParams.get("select"));
const rootTenantId = searchParams.get("rootTenantId") || undefined;
const tenantId =
searchParams.get("tenantId") ||
searchParams.get("companyTenantId") ||
undefined;
const [includeDescendants, setIncludeDescendants] = React.useState(
searchParams.get("includeDescendants") !== "false",
);
const showDescendantToggle =
searchParams.get("showDescendantToggle") !== "false";
const [searchQuery, setSearchQuery] = React.useState("");
const [selectedKeys, setSelectedKeys] = React.useState<Set<string>>(
() => new Set(),
);
const tenantsQuery = useQuery({
queryKey: ["org-picker-tenants"],
queryFn: () => fetchTenants(10000, 0),
});
const usersQuery = useQuery({
queryKey: ["org-picker-users"],
queryFn: () => fetchUsers(5000, 0),
});
React.useEffect(() => {
postPickerMessage({ type: "orgfront:picker:ready" });
}, []);
const tree = React.useMemo(() => {
return buildOrgPickerTree({
tenants: tenantsQuery.data?.items ?? [],
users: select === "tenant" ? [] : (usersQuery.data?.items ?? []),
rootTenantId,
tenantId,
});
}, [rootTenantId, select, tenantId, tenantsQuery.data, usersQuery.data]);
const selectedItems = React.useMemo(
() =>
collectSelectedNodes({
roots: tree.roots,
selectedKeys,
includeDescendants: mode === "multiple" && includeDescendants,
select,
}),
[includeDescendants, mode, select, selectedKeys, tree.roots],
);
const checkedKeys = React.useMemo(
() =>
collectCheckedKeys({
roots: tree.roots,
selectedKeys,
includeDescendants: mode === "multiple" && includeDescendants,
select,
}),
[includeDescendants, mode, select, selectedKeys, tree.roots],
);
const filteredRoots = React.useMemo(
() => filterPickerTree(tree.roots, searchQuery),
[searchQuery, tree.roots],
);
const handleSingleSelect = (node: OrgPickerTreeNode) => {
setSelectedKeys(new Set([nodeKey(node)]));
};
const handleToggle = (node: OrgPickerTreeNode, checked: boolean) => {
setSelectedKeys((current) => {
const next = new Set(current);
const key = nodeKey(node);
if (checked) next.add(key);
else next.delete(key);
return next;
});
};
const confirmSelection = () => {
const payload: OrgPickerResult = {
mode,
selections: selectedItems,
};
postPickerMessage({ type: "orgfront:picker:confirm", payload });
};
const cancelSelection = () => {
postPickerMessage({ type: "orgfront:picker:cancel" });
};
const isLoading = tenantsQuery.isLoading || usersQuery.isLoading;
const isError = tenantsQuery.isError || usersQuery.isError;
React.useEffect(() => {
const htmlOverflow = document.documentElement.style.overflow;
const bodyOverflow = document.body.style.overflow;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
document.documentElement.style.overflow = htmlOverflow;
document.body.style.overflow = bodyOverflow;
};
}, []);
React.useEffect(() => {
if (!isError) return;
postPickerMessage({
type: "orgfront:picker:error",
error: "org_picker_load_failed",
});
}, [isError]);
if (isLoading) {
return (
<div className="grid min-h-screen place-items-center bg-background p-6 text-muted-foreground">
...
</div>
);
}
if (isError) {
return (
<div className="grid min-h-screen place-items-center bg-background p-6 text-destructive">
.
</div>
);
}
return (
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
<main className="flex min-h-0 flex-1 flex-col">
<div
className="shrink-0 border-b border-border bg-background p-2"
data-testid="org-picker-search-section"
>
<div className="grid grid-cols-[minmax(0,1fr),auto] items-end gap-2">
<div>
<label className="sr-only" htmlFor="org-picker-search">
/
</label>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
size={16}
/>
<input
id="org-picker-search"
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="ID, 이름, 이메일, 메타데이터"
type="search"
value={searchQuery}
/>
</div>
</div>
{mode === "multiple" && showDescendantToggle ? (
<label
className="inline-flex h-9 items-center gap-2 whitespace-nowrap text-sm"
data-testid="org-picker-descendant-toggle"
>
<input
checked={includeDescendants}
className="h-3.5 w-3.5"
onChange={(event) =>
setIncludeDescendants(event.target.checked)
}
type="checkbox"
/>
<span> </span>
</label>
) : null}
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto p-3"
data-testid="org-picker-tree-scroll"
>
{filteredRoots.length > 0 ? (
<OrgPickerTree
mode={mode}
onSingleSelect={handleSingleSelect}
onToggle={handleToggle}
roots={filteredRoots}
select={select}
selectedKeys={checkedKeys}
/>
) : (
<div className="grid min-h-40 place-items-center rounded-md border border-dashed border-border bg-background p-6 text-center text-sm text-muted-foreground">
.
</div>
)}
</div>
</main>
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-background px-3 py-2">
<div className="min-w-0 text-sm text-muted-foreground">
{selectedItems.length > 0
? `${selectedItems.length}개 항목 선택됨`
: "선택된 항목이 없습니다."}
</div>
<div className="flex items-center gap-2">
<Button onClick={cancelSelection} type="button" variant="outline">
<X size={16} />
</Button>
<Button
disabled={selectedItems.length === 0}
onClick={confirmSelection}
type="button"
>
<Check size={16} />
</Button>
</div>
</footer>
</div>
);
}
export function OrgPickerPage() {
const location = useLocation();
const [options, setOptions] = React.useState<OrgPickerEmbedOptions>(() =>
parseOrgPickerEmbedOptions(location.search),
);
const pickerSrc = buildOrgPickerEmbedSrc(options);
return (
<div className="space-y-4">
<header className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Picker Workbench
</p>
<h1 className="text-2xl font-semibold"> </h1>
</div>
<div className="rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground">
{pickerSrc}
</div>
</header>
<section className="grid gap-3 rounded-md border border-border bg-card p-4 md:grid-cols-2 lg:grid-cols-[1fr,1fr,1fr,auto,auto,auto,auto] lg:items-end">
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.mode}
onChange={(event) =>
setOptions((current) => ({
...current,
mode: event.target.value as OrgPickerMode,
}))
}
>
<option value="multiple"> </option>
<option value="single"> </option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<select
className="h-10 w-full rounded-md border border-input bg-background px-3"
value={options.select}
onChange={(event) =>
setOptions((current) => ({
...current,
select: event.target.value as OrgPickerSelectableType,
}))
}
>
<option value="both">&</option>
<option value="tenant"></option>
<option value="user"></option>
</select>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant ID</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
setOptions((current) => ({
...current,
tenantId: event.target.value,
}))
}
placeholder="company-baron"
type="text"
value={options.tenantId}
/>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.includeDescendants}
disabled={options.mode === "single"}
onChange={(event) =>
setOptions((current) => ({
...current,
includeDescendants: event.target.checked,
}))
}
type="checkbox"
/>
<span> </span>
</label>
<label className="flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm">
<input
checked={options.showDescendantToggle}
disabled={options.mode === "single"}
onChange={(event) =>
setOptions((current) => ({
...current,
showDescendantToggle: event.target.checked,
}))
}
type="checkbox"
/>
<span> </span>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
max={1600}
min={240}
onChange={(event) =>
setOptions((current) => ({
...current,
width: Number.parseInt(event.target.value || "400", 10),
}))
}
type="number"
value={options.width}
/>
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground"> </span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
max={1600}
min={240}
onChange={(event) =>
setOptions((current) => ({
...current,
height: Number.parseInt(event.target.value || "600", 10),
}))
}
type="number"
value={options.height}
/>
</label>
</section>
<div
className="max-w-full resize overflow-auto rounded-md border border-border bg-card"
style={{
width: options.width,
height: options.height,
}}
>
<iframe
className="h-full w-full bg-background"
src={pickerSrc}
title="조직 선택기 테스트"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
type UserAppointment = {
tenantId?: string;
tenantSlug?: string;
jobTitle?: string;
position?: string;
};
type TenantIdentity = Pick<TenantSummary, "id" | "slug">;
function normalizeText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function getUserAppointments(user: UserSummary): UserAppointment[] {
const rawAppointments = user.metadata?.additionalAppointments;
if (!Array.isArray(rawAppointments)) return [];
return rawAppointments
.filter(
(item): item is Record<string, unknown> =>
typeof item === "object" && item !== null,
)
.map((item) => ({
tenantId: normalizeText(item.tenantId),
tenantSlug: normalizeText(item.tenantSlug),
jobTitle: normalizeText(item.jobTitle),
position: normalizeText(item.position),
}));
}
export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
const appointment = getUserAppointments(user).find((item) => {
if (tenant?.id && item.tenantId === tenant.id) return true;
if (
tenant?.slug &&
item.tenantSlug &&
item.tenantSlug.toLowerCase() === tenant.slug.toLowerCase()
) {
return true;
}
return false;
});
return {
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
position: appointment?.position || normalizeText(user.position),
};
}
export function getOrgChartUserDisplayName(
user: UserSummary,
tenant?: TenantIdentity,
) {
const { jobTitle, position } = getUserOrgProfile(user, tenant);
const baseName = user.name.trim();
if (jobTitle && position) return `${baseName} ${position}[${jobTitle}]`;
if (jobTitle) return `${baseName}[${jobTitle}]`;
if (position) return `${baseName} ${position}`;
return baseName;
}

View File

@@ -0,0 +1,219 @@
import { useQuery } from "@tanstack/react-query";
import {
Briefcase,
Building2,
Fingerprint,
Mail,
Shield,
User,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
import { fetchMe } from "../auth/authApi";
import ProfileTenantSwitcher from "./ProfileTenantSwitcher";
function ProfilePage() {
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const {
data: profile,
isLoading,
error,
} = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const [activeTab, setActiveTab] = useState<"basic" | "role">("basic");
if (isLoading) {
return (
<div className="p-8 text-center">
{t("ui.dev.profile.loading", "Loading profile...")}
</div>
);
}
if (error || !profile) {
return (
<div className="p-8 text-center text-red-500">
{t("ui.dev.profile.error", "Failed to load profile information.")}
</div>
);
}
// Fallback to token information if API data is incomplete
const displayTenant =
profile.tenant?.name ||
profile.tenantId ||
auth.user?.profile?.tenant_id?.toString() ||
"-";
const displayCompanyCode =
profile.companyCode || auth.user?.profile?.companyCode?.toString() || "-";
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div>
<h1 className="text-3xl font-black tracking-tight">
{t("ui.dev.profile.title", "내 정보")}
</h1>
<p className="text-muted-foreground mt-2">
{t(
"ui.dev.profile.subtitle",
"사용자 상세 정보 및 할당된 역할(Role)을 확인합니다.",
)}
</p>
</div>
<div className="flex space-x-1 border-b border-border pb-px">
<button
type="button"
onClick={() => setActiveTab("basic")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "basic"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
{t("ui.dev.profile.tab.basic", "기본 정보")}
</button>
<button
type="button"
onClick={() => setActiveTab("role")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "role"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
{t("ui.dev.profile.tab.role", "권한 및 역할")}
</button>
</div>
<div className="pt-4">
{activeTab === "basic" && (
<div className="grid gap-6 md:grid-cols-2">
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
{t("ui.dev.profile.basic.title", "사용자 정보")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Fingerprint className="h-4 w-4" />
{t("ui.dev.profile.basic.id", "User ID")}
</p>
<p className="text-sm break-all font-mono bg-muted/50 p-2 rounded-md">
{profile.id}
</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<User className="h-4 w-4" />
{t("ui.dev.profile.basic.name", "Name")}
</p>
<p className="text-sm">{profile.name}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Mail className="h-4 w-4" />
{t("ui.dev.profile.basic.email", "Email")}
</p>
<p className="text-sm">{profile.email}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Briefcase className="h-4 w-4" />
{t("ui.dev.profile.basic.phone", "Phone")}
</p>
<p className="text-sm">{profile.phone || "-"}</p>
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
{t("ui.dev.profile.org.title", "조직 정보")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
{t("ui.dev.profile.org.tenant", "테넌트")}
</p>
<p className="text-sm">{displayTenant}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
{t("ui.dev.profile.org.company_code", "회사 코드")}
</p>
<p className="text-sm">{displayCompanyCode}</p>
</div>
</CardContent>
</Card>
<ProfileTenantSwitcher />
</div>
)}
{activeTab === "role" && (
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
{t("ui.dev.profile.role.title", "시스템 역할")}
</CardTitle>
<CardDescription>
{t(
"ui.dev.profile.role.description",
"현재 계정에 부여된 권한 등급입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 bg-muted/30 p-4 rounded-lg border border-border">
<div className="h-12 w-12 rounded-full bg-primary/20 flex items-center justify-center text-primary shrink-0">
<Briefcase className="h-6 w-6" />
</div>
<div className="flex flex-col gap-1 w-full">
<p className="text-sm text-muted-foreground font-medium uppercase tracking-wider">
{t("ui.dev.profile.role.current", "Current Role")}
</p>
<p className="text-xl font-bold mt-1">
{t(
`ui.common.role.${profile.role}`,
profile.role.toUpperCase(),
)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t(
`ui.dev.profile.role.desc_${profile.role}`,
"시스템 역할에 대한 설명이 제공되지 않았습니다.",
)}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
export default ProfilePage;

View File

@@ -0,0 +1,92 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, Save } from "lucide-react";
import { useState } from "react";
import { Button } from "../../components/ui/button";
import { toast } from "../../components/ui/use-toast";
import { fetchMyTenants } from "../../lib/devApi";
import { t } from "../../lib/i18n";
export default function ProfileTenantSwitcher() {
const queryClient = useQueryClient();
const { data: tenants, isLoading } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
});
const [selectedTenantId, setSelectedTenantId] = useState<string>(() => {
return window.localStorage.getItem("dev_tenant_id") || "";
});
const handleSave = () => {
window.localStorage.setItem("dev_tenant_id", selectedTenantId);
// Invalidate queries to refresh data with new tenant context
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] !== "userMe" && query.queryKey[0] !== "myTenants",
});
toast(t("ui.dev.tenant.switch_success", "테넌트 전환 완료"), "success");
};
if (isLoading || !tenants || tenants.length === 0) {
return null;
}
// If there's only one tenant, the user doesn't need to switch.
// Still show it as read-only or hidden. Let's just show it as disabled.
const isSingleTenant = tenants.length <= 1;
return (
<div className="flex flex-col gap-4 mt-6 p-4 rounded-lg border border-border bg-card">
<div className="flex items-center gap-2 mb-2">
<Building2 className="h-5 w-5 text-primary" />
<h3 className="font-semibold">
{t("ui.dev.tenant.workspace", "작업 테넌트 (컨텍스트)")}
</h3>
</div>
<p className="text-sm text-muted-foreground -mt-2 mb-2">
{t(
"ui.dev.tenant.workspace_desc",
"현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다.",
)}
</p>
<div className="flex items-center gap-3">
<select
aria-label={t("ui.dev.tenant.workspace", "작업 테넌트 (컨텍스트)")}
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
disabled={isSingleTenant}
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"
>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name}
</option>
))}
</select>
<Button
type="button"
onClick={handleSave}
disabled={isSingleTenant}
className="gap-2"
>
<Save size={16} />
{t("ui.common.save", "저장")}
</Button>
</div>
{isSingleTenant && (
<p className="text-xs text-muted-foreground mt-1">
{t(
"ui.dev.tenant.single_notice",
"단일 테넌트에 소속되어 전환할 필요가 없습니다.",
)}
</p>
)}
</div>
);
}