forked from baron/baron-sso
feat: integrate orgfront and expose internal ids
This commit is contained in:
422
orgfront/src/features/audit/AuditLogsPage.tsx
Normal file
422
orgfront/src/features/audit/AuditLogsPage.tsx
Normal 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;
|
||||
35
orgfront/src/features/auth/AuthCallbackPage.tsx
Normal file
35
orgfront/src/features/auth/AuthCallbackPage.tsx
Normal 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>;
|
||||
}
|
||||
60
orgfront/src/features/auth/AuthGuard.tsx
Normal file
60
orgfront/src/features/auth/AuthGuard.tsx
Normal 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 />;
|
||||
}
|
||||
111
orgfront/src/features/auth/AuthPage.tsx
Normal file
111
orgfront/src/features/auth/AuthPage.tsx
Normal 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;
|
||||
126
orgfront/src/features/auth/LoginPage.tsx
Normal file
126
orgfront/src/features/auth/LoginPage.tsx
Normal 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;
|
||||
24
orgfront/src/features/auth/authApi.ts
Normal file
24
orgfront/src/features/auth/authApi.ts
Normal 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;
|
||||
}
|
||||
600
orgfront/src/features/clients/ClientConsentsPage.tsx
Normal file
600
orgfront/src/features/clients/ClientConsentsPage.tsx
Normal 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;
|
||||
540
orgfront/src/features/clients/ClientDetailsPage.tsx
Normal file
540
orgfront/src/features/clients/ClientDetailsPage.tsx
Normal 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;
|
||||
1645
orgfront/src/features/clients/ClientGeneralPage.tsx
Normal file
1645
orgfront/src/features/clients/ClientGeneralPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
527
orgfront/src/features/clients/ClientsPage.tsx
Normal file
527
orgfront/src/features/clients/ClientsPage.tsx
Normal 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;
|
||||
306
orgfront/src/features/clients/routes/ClientFederationPage.tsx
Normal file
306
orgfront/src/features/clients/routes/ClientFederationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
294
orgfront/src/features/dashboard/DashboardPage.tsx
Normal file
294
orgfront/src/features/dashboard/DashboardPage.tsx
Normal 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;
|
||||
135
orgfront/src/features/orgchart/pickerTree.ts
Normal file
135
orgfront/src/features/orgchart/pickerTree.ts
Normal 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;
|
||||
}
|
||||
98
orgfront/src/features/orgchart/pickerTypes.ts
Normal file
98
orgfront/src/features/orgchart/pickerTypes.ts
Normal 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()}`;
|
||||
}
|
||||
881
orgfront/src/features/orgchart/routes/OrgChartPage.tsx
Normal file
881
orgfront/src/features/orgchart/routes/OrgChartPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
orgfront/src/features/orgchart/routes/OrgFrontLayout.tsx
Normal file
48
orgfront/src/features/orgchart/routes/OrgFrontLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
709
orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
Normal file
709
orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
orgfront/src/features/orgchart/userDisplay.ts
Normal file
63
orgfront/src/features/orgchart/userDisplay.ts
Normal 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;
|
||||
}
|
||||
219
orgfront/src/features/profile/ProfilePage.tsx
Normal file
219
orgfront/src/features/profile/ProfilePage.tsx
Normal 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;
|
||||
92
orgfront/src/features/profile/ProfileTenantSwitcher.tsx
Normal file
92
orgfront/src/features/profile/ProfileTenantSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user