1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/overview/GlobalOverviewPage.tsx
2026-05-20 13:41:15 +09:00

885 lines
29 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Activity,
AlertTriangle,
CheckCircle2,
LayoutDashboard,
Layers3,
ShieldCheck,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import {
OverviewAxisNotes,
OverviewMetric,
OverviewSelectionChips,
} from "../../../../common/core/components/overview";
import {
type ClientSummary,
type RPUsageDailyMetric,
type RPUsagePeriod,
fetchClients,
fetchDevRPUsageDaily,
fetchDevStats,
fetchDeveloperRequestStatus,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
type ClientDistribution = {
activeClients: number;
headlessClients: number;
pkceClients: number;
privateClients: number;
};
type DailyPoint = {
date: string;
loginRequests: number;
otherRequests: number;
};
type SeriesSummary = {
key: string;
clientLabel: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
type MultiLineSeries = {
key: string;
clientLabel: string;
color: UsageChartPalette;
points: DailyPoint[];
};
type ClientFilterOption = {
id: string;
label: string;
};
type UsageChartPalette = {
bar: string;
line: string;
point: string;
};
const usageChartPalettes: UsageChartPalette[] = [
{ bar: "#7dd3fc", line: "#10b981", point: "#059669" },
{ bar: "#f9a8d4", line: "#f97316", point: "#ea580c" },
{ bar: "#c4b5fd", line: "#6366f1", point: "#4f46e5" },
{ bar: "#86efac", line: "#14b8a6", point: "#0f766e" },
{ bar: "#fdba74", line: "#ef4444", point: "#dc2626" },
{ bar: "#93c5fd", line: "#8b5cf6", point: "#7c3aed" },
];
function buildClientDistribution(clients: ClientSummary[]): ClientDistribution {
return clients.reduce<ClientDistribution>(
(summary, client) => {
if (client.status === "active") {
summary.activeClients += 1;
}
if (client.metadata?.headless_login_enabled === true) {
summary.headlessClients += 1;
}
if (client.type === "pkce") {
summary.pkceClients += 1;
} else {
summary.privateClients += 1;
}
return summary;
},
{
activeClients: 0,
headlessClients: 0,
pkceClients: 0,
privateClients: 0,
},
);
}
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
const byDate = new Map<string, DailyPoint>();
for (const row of rows) {
const current =
byDate.get(row.date) ??
({
date: row.date,
loginRequests: 0,
otherRequests: 0,
} satisfies DailyPoint);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
byDate.set(row.date, current);
}
return Array.from(byDate.values()).sort((left, right) =>
left.date.localeCompare(right.date),
);
}
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
const bySeries = new Map<string, SeriesSummary>();
for (const row of rows) {
const key = row.clientId;
const current =
bySeries.get(key) ??
({
key,
clientLabel: row.clientName || row.clientId,
loginRequests: 0,
otherRequests: 0,
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values()).sort(
(left, right) =>
right.loginRequests +
right.otherRequests -
(left.loginRequests + left.otherRequests),
);
}
function buildMultiLineSeries(rows: RPUsageDailyMetric[]): MultiLineSeries[] {
const dates = summarizeDaily(rows).map((point) => point.date);
const byClient = new Map<
string,
{
clientLabel: string;
byDate: Map<string, DailyPoint>;
}
>();
for (const row of rows) {
const current = byClient.get(row.clientId) ?? {
clientLabel: row.clientName || row.clientId,
byDate: new Map<string, DailyPoint>(),
};
const point =
current.byDate.get(row.date) ??
({
date: row.date,
loginRequests: 0,
otherRequests: 0,
} satisfies DailyPoint);
point.loginRequests += row.loginRequests;
point.otherRequests += row.otherRequests;
current.byDate.set(row.date, point);
byClient.set(row.clientId, current);
}
return Array.from(byClient.entries())
.sort((left, right) =>
left[1].clientLabel.localeCompare(right[1].clientLabel),
)
.map(([clientId, entry], index) => ({
key: clientId,
clientLabel: entry.clientLabel,
color: usageChartPalettes[index % usageChartPalettes.length],
points: dates.map(
(date) =>
entry.byDate.get(date) ??
({
date,
loginRequests: 0,
otherRequests: 0,
} satisfies DailyPoint),
),
}));
}
function parseDateParts(date: string) {
const parts = date.split("-");
if (parts.length === 3) {
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
monthText: parts[1],
dayText: parts[2],
};
}
return null;
}
function getISOWeekNumber(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeekThursday(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
return date;
}
function formatDate(value?: string) {
if (!value) {
return "-";
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleDateString();
}
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
const parts = parseDateParts(date);
if (!parts) {
return date;
}
if (period === "month") {
return `${parts.monthText}`;
}
if (period === "week") {
const weekNumber = String(
getISOWeekNumber(parts.year, parts.month, parts.day),
).padStart(2, "0");
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
const weekMonth = weekThursday.getUTCMonth() + 1;
const weekDay = weekThursday.getUTCDate();
const weekMonthText = String(weekMonth).padStart(2, "0");
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
return `${weekNumber}(${weekMonthText}${weekOfMonth}주)`;
}
return `${parts.monthText}.${parts.dayText}`;
}
function formatMetric(value: number | undefined) {
return value === undefined ? "-" : value.toLocaleString();
}
function RPUsageMixedChart({
period,
rows,
palette,
multiLineSeries,
}: {
period: RPUsagePeriod;
rows: RPUsageDailyMetric[];
palette?: UsageChartPalette;
multiLineSeries?: MultiLineSeries[];
}) {
const colors = palette ?? usageChartPalettes[0];
const daily = summarizeDaily(rows);
const series = summarizeSeries(rows);
const topSeries = series.slice(0, 5);
const seriesByKey = new Map(series.map((item) => [item.key, item]));
const chartWidth = 720;
const chartHeight = 230;
const padX = 48;
const padTop = 40;
const padBottom = 34;
const innerWidth = chartWidth - padX * 2;
const innerHeight = chartHeight - padTop - padBottom;
const maxValue = Math.max(1, ...daily.map((point) => point.loginRequests));
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
const y = (value: number) =>
padTop + innerHeight - (value / maxValue) * innerHeight;
const x = (index: number) => padX + slot * index + slot / 2;
const linePoints = daily
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
.join(" ");
const multiLinePoints = multiLineSeries?.map((seriesItem) => ({
...seriesItem,
pointsAttr: seriesItem.points
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
.join(" "),
}));
if (daily.length === 0) {
return (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
{t("msg.dev.dashboard.chart.empty", "표시할 RP 이용 집계가 없습니다.")}
</div>
);
}
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={t("ui.dev.dashboard.chart.aria", "RP 요청 현황")}
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title>{t("ui.dev.dashboard.chart.aria", "RP 요청 현황")}</title>
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
return (
<g key={ratio}>
<line
x1={padX}
x2={chartWidth - padX}
y1={gridY}
y2={gridY}
stroke="currentColor"
className="text-border"
strokeWidth="1"
/>
<text
x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
return (
<g key={point.date}>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
{!multiLinePoints || multiLinePoints.length === 0 ? (
<>
<polyline
points={linePoints}
fill="none"
stroke={colors.line}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
fill={colors.point}
stroke="hsl(var(--background))"
strokeWidth="2"
/>
))}
</>
) : (
multiLinePoints.map((seriesItem) => (
<g key={seriesItem.key}>
<polyline
points={seriesItem.pointsAttr}
fill="none"
stroke={seriesItem.color.line}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{seriesItem.points.map((point, index) => (
<circle
key={`${seriesItem.key}-${point.date}`}
cx={x(index)}
cy={y(point.loginRequests)}
r="3.5"
fill={seriesItem.color.point}
stroke="hsl(var(--background))"
strokeWidth="2"
/>
))}
</g>
))
)}
</svg>
</div>
<OverviewAxisNotes
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
/>
{multiLinePoints && multiLinePoints.length > 0 ? (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{multiLinePoints.map((item) => {
const seriesItem = seriesByKey.get(item.key);
return (
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color.line }}
/>
<span className="font-medium">{item.clientLabel}</span>
{seriesItem ? (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
"ui.dev.dashboard.chart.series",
"로그인 {{login}} / 사용자 {{subjects}}",
{
login: seriesItem.loginRequests.toLocaleString(),
subjects: seriesItem.uniqueSubjects.toLocaleString(),
},
)}
</span>
) : null}
</div>
);
})}
</div>
) : topSeries.length > 0 ? (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{topSeries.map((item) => (
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span className="font-medium">{item.clientLabel}</span>
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
"ui.dev.dashboard.chart.series",
"로그인 {{login}} / 사용자 {{subjects}}",
{
login: item.loginRequests.toLocaleString(),
subjects: item.uniqueSubjects.toLocaleString(),
},
)}
</span>
</div>
))}
</div>
) : null}
</div>
);
}
function GlobalOverviewPage() {
const navigate = useNavigate();
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [selectedClientIds, setSelectedClientIds] = useState<string[]>([]);
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled: hasAccessToken && role === "user",
});
const statsQuery = useQuery({
queryKey: ["dev-dashboard-stats"],
queryFn: fetchDevStats,
retry: false,
});
const clientsQuery = useQuery({
queryKey: ["dev-dashboard-clients"],
queryFn: fetchClients,
retry: false,
});
const usageQuery = useQuery({
queryKey: ["dev-dashboard-rp-usage", usageDays, period],
queryFn: () =>
fetchDevRPUsageDaily({
days: usageDays,
period,
}),
retry: false,
});
const clients = clientsQuery.data?.items ?? [];
const hasDeveloperAccess =
role === "super_admin" ||
role === "tenant_admin" ||
role === "rp_admin" ||
requestStatus?.status === "approved";
const isDeveloperRequestPending = requestStatus?.status === "pending";
const canRequestDeveloperAccess =
(role === "user" || role === "tenant_member") &&
!isLoadingRequestStatus &&
!hasDeveloperAccess &&
!isDeveloperRequestPending;
const distribution = useMemo(
() => buildClientDistribution(clients),
[clients],
);
const visibleClients = useMemo(
() =>
[...clients]
.sort((left, right) => {
const statusCompare = (left.status || "").localeCompare(
right.status || "",
);
if (statusCompare !== 0) {
return statusCompare;
}
return (left.name || left.id).localeCompare(right.name || right.id);
})
.slice(0, 6),
[clients],
);
const clientFilterOptions = useMemo<ClientFilterOption[]>(
() =>
[...clients]
.map((client) => ({
id: client.id,
label: client.name || client.id,
}))
.sort((left, right) => left.label.localeCompare(right.label)),
[clients],
);
const stats = statsQuery.data;
const usageRows = usageQuery.data?.items ?? [];
const filteredUsageRows = useMemo(() => {
if (selectedClientIds.length === 0) {
return usageRows;
}
const selectedSet = new Set(selectedClientIds);
return usageRows.filter((row) => selectedSet.has(row.clientId));
}, [selectedClientIds, usageRows]);
const selectedMultiLineSeries = useMemo(
() => buildMultiLineSeries(filteredUsageRows),
[filteredUsageRows],
);
const usageError = usageQuery.error as AxiosError<{ error?: string }> | null;
const usageStatus = usageError?.response?.status;
const usageErrorMessage =
usageError?.response?.data?.error ?? usageError?.message ?? "";
const usageErrorText =
usageStatus === 403
? t(
"msg.dev.dashboard.chart.forbidden",
"현재 계정에는 RP 이용 통계를 볼 권한이 없습니다.",
)
: usageStatus === 503
? t(
"msg.dev.dashboard.chart.service_unavailable",
"RP 이용 통계 집계 서비스가 아직 준비되지 않았습니다.",
)
: usageStatus === 500
? t(
"msg.dev.dashboard.chart.server_error",
"RP 이용 통계 조회 중 서버 오류가 발생했습니다.",
)
: t(
"msg.dev.dashboard.chart.unavailable_with_reason",
"RP 이용 통계 API 응답을 확인할 수 없습니다. {{reason}}",
{
reason:
usageErrorMessage ||
t("err.common.unknown", "알 수 없는 오류"),
},
);
const isAllClientsSelected = selectedClientIds.length === 0;
const toggleClientSelection = (clientId: string) => {
setSelectedClientIds((current) => {
if (current.includes(clientId)) {
const next = current.filter((item) => item !== clientId);
return next;
}
return [...current, clientId];
});
};
const selectAllClients = () => {
setSelectedClientIds([]);
};
if ((role === "user" || role === "tenant_member") && isLoadingRequestStatus) {
return (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
</div>
);
}
if (!hasDeveloperAccess) {
return (
<div className="rounded-xl border border-border/60 bg-card p-8 text-center">
<div className="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight">
{t("ui.common.overview.title", "운영 현황")}
</h2>
<p className="font-medium text-foreground">
{isDeveloperRequestPending
? t(
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)
: t(
"msg.dev.dashboard.access_denied",
"대시보드는 개발자 권한이 있어야 볼 수 있습니다.",
)}
</p>
<p className="text-sm text-muted-foreground">
{isDeveloperRequestPending
? t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)
: t(
"msg.dev.dashboard.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
</p>
{(isDeveloperRequestPending || canRequestDeveloperAccess) && (
<button
type="button"
className="font-bold text-primary hover:underline"
onClick={() => navigate("/developer-requests")}
>
{isDeveloperRequestPending
? t("ui.dev.nav.developer_request", "개발자 권한 신청")
: t("ui.dev.welcome.btn_request", "개발자 등록 신청하기")}
</button>
)}
</div>
</div>
);
}
return (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<LayoutDashboard size={20} />
</div>
<div className="space-y-1">
<h2 className="text-3xl font-semibold">
{t("ui.common.overview.title", "운영 현황")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.description",
"연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다.",
)}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<OverviewMetric
icon={<ShieldCheck size={14} />}
label={t("ui.dev.dashboard.summary.total_clients", "총 RP 수")}
value={formatMetric(stats?.total_clients ?? clients.length)}
/>
<OverviewMetric
icon={<CheckCircle2 size={14} />}
label={t("ui.dev.dashboard.summary.active_clients", "활성 RP 수")}
value={formatMetric(distribution.activeClients)}
/>
<OverviewMetric
icon={<Activity size={14} />}
label={t("ui.dev.dashboard.summary.active_sessions", "활성 세션 수")}
value={formatMetric(stats?.active_sessions)}
/>
<OverviewMetric
icon={<AlertTriangle size={14} />}
label={t(
"ui.dev.dashboard.summary.auth_failures_24h",
"24시간 인증 실패 수",
)}
value={formatMetric(stats?.auth_failures_24h)}
/>
</div>
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-base font-semibold">
{t(
"ui.dev.dashboard.chart.title",
"애플리케이션별 로그인요청/기타 요청 현황",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.chart.filter_description",
"전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", t("ui.common.chart.period.day", "일")],
["week", t("ui.common.chart.period.week", "주")],
["month", t("ui.common.chart.period.month", "월")],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={period === value}
onClick={() => setPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
period === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
</div>
<OverviewSelectionChips
allLabel={t("ui.dev.dashboard.chart.filter_all", "전체")}
options={clientFilterOptions}
selectedIds={selectedClientIds}
onSelectAll={selectAllClients}
onToggle={toggleClientSelection}
/>
{usageQuery.isError ? (
<div className="text-sm text-muted-foreground">{usageErrorText}</div>
) : isAllClientsSelected ? (
<RPUsageMixedChart rows={filteredUsageRows} period={period} />
) : (
<RPUsageMixedChart
rows={filteredUsageRows}
period={period}
multiLineSeries={selectedMultiLineSeries}
/>
)}
</section>
<div className="grid gap-4 lg:grid-cols-[0.9fr,1.1fr]">
<section className="rounded-xl border border-border/60 bg-card p-6">
<div className="flex items-center gap-2">
<Layers3 size={18} className="text-primary" />
<h3 className="text-base font-semibold">
{t(
"ui.dev.dashboard.distribution.title",
"애플리케이션 구성 요약",
)}
</h3>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.distribution.description",
"애플리케이션 유형 분포와 server side app의 headless login 사용 현황을 빠르게 확인합니다.",
)}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border/60 bg-secondary/30 p-4">
<p className="text-sm text-muted-foreground">
{t("ui.dev.dashboard.distribution.private", "Server side App")}
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{distribution.privateClients.toLocaleString()}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(
"ui.dev.dashboard.distribution.headless_hint",
"이 중 Headless Login 사용 {{count}}",
{
count: distribution.headlessClients.toLocaleString(),
},
)}
</p>
</div>
<div className="rounded-xl border border-border/60 bg-secondary/30 p-4">
<p className="text-sm text-muted-foreground">
{t("ui.dev.dashboard.distribution.pkce", "PKCE")}
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{distribution.pkceClients.toLocaleString()}
</p>
</div>
</div>
</section>
<section className="rounded-xl border border-border/60 bg-card p-6">
<div className="flex items-center gap-2">
<Layers3 size={18} className="text-primary" />
<h3 className="text-base font-semibold">
{t("ui.dev.dashboard.recent.title", "내 애플리케이션")}
</h3>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.recent.empty",
"현재 계정이 접근할 수 있는 RP를 확인합니다.",
)}
</p>
<div className="mt-4 space-y-2">
{visibleClients.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.recent.none",
"표시할 연동 앱이 없습니다.",
)}
</p>
) : (
visibleClients.map((client) => (
<div
key={client.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border/40 px-3 py-2"
>
<div className="min-w-0">
<p className="truncate font-semibold">
{client.name || t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="truncate text-xs text-muted-foreground">
{client.id}
</p>
</div>
<div className="text-right text-xs text-muted-foreground">
<p>
{client.metadata?.headless_login_enabled === true
? t(
"ui.dev.clients.type.private_headless",
"Server side App (Headless Login)",
)
: client.type === "private"
? t("ui.dev.clients.type.private", "Server side App")
: t("ui.dev.clients.type.pkce", "PKCE")}
</p>
<p>
{client.status === "active"
? t("ui.dev.clients.status.active", "활성")
: client.status === "inactive"
? t("ui.dev.clients.status.inactive", "비활성")
: client.status || "-"}
</p>
<p>
{t("ui.dev.clients.table.created_at", "생성일")}{" "}
{formatDate(client.createdAt)}
</p>
</div>
</div>
))
)}
</div>
</section>
</div>
</div>
);
}
export default GlobalOverviewPage;