1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
2026-05-13 08:42:45 +09:00
8 changed files with 217 additions and 113 deletions

View File

@@ -9,15 +9,19 @@ import {
ShieldCheck,
} from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import {
type ClientSummary,
fetchClients,
fetchDeveloperRequestStatus,
fetchDevRPUsageDaily,
fetchDevStats,
type RPUsageDailyMetric,
type RPUsagePeriod,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
type ClientDistribution = {
activeClients: number;
@@ -135,14 +139,12 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
.sort(
(left, right) =>
right.loginRequests +
right.otherRequests -
(left.loginRequests + left.otherRequests),
)
.slice(0, 5);
return Array.from(bySeries.values()).sort(
(left, right) =>
right.loginRequests +
right.otherRequests -
(left.loginRequests + left.otherRequests),
);
}
function buildMultiLineSeries(rows: RPUsageDailyMetric[]): MultiLineSeries[] {
@@ -222,6 +224,17 @@ function getISOWeekThursday(year: number, month: number, day: number) {
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) {
@@ -280,20 +293,17 @@ function RPUsageMixedChart({
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 = 32;
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 + point.otherRequests),
...daily.map((point) => point.loginRequests),
);
const maxValue = Math.max(1, ...daily.map((point) => point.loginRequests));
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
const y = (value: number) =>
padTop + innerHeight - (value / maxValue) * innerHeight;
const x = (index: number) => padX + slot * index + slot / 2;
@@ -325,40 +335,6 @@ function RPUsageMixedChart({
className="h-[235px] min-w-[720px] w-full"
>
<title>{t("ui.dev.dashboard.chart.aria", "RP 요청 현황")}</title>
<g transform="translate(510 10)">
<rect
x="0"
y="3"
width="10"
height="10"
rx="2"
fill={colors.bar}
fillOpacity="0.7"
/>
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
{t("ui.dev.dashboard.chart.other_requests", "기타 요청")}
</text>
{!multiLinePoints || multiLinePoints.length === 0 ? (
<>
<line
x1="78"
x2="98"
y1="8"
y2="8"
stroke={colors.line}
strokeWidth="3"
strokeLinecap="round"
/>
<text
x="104"
y="12"
className="fill-muted-foreground text-[11px]"
>
{t("ui.dev.dashboard.chart.login_requests", "로그인 요청")}
</text>
</>
) : null}
</g>
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
@@ -386,18 +362,8 @@ function RPUsageMixedChart({
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight = (point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
fill={colors.bar}
fillOpacity="0.7"
/>
<text
x={center}
y={chartHeight - 12}
@@ -459,30 +425,55 @@ function RPUsageMixedChart({
</svg>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{t("ui.dev.dashboard.chart.x_axis", "X축: 기간")}</span>
<span>{t("ui.dev.dashboard.chart.y_axis", "Y축: 로그인 요청 수")}</span>
</div>
{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) => (
<div key={item.key} className="flex min-w-0 items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color.line }}
/>
<span className="truncate font-medium">{item.clientLabel}</span>
</div>
))}
{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>
) : series.length > 0 ? (
) : 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">
{series.map((item) => (
<div key={item.key} className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{item.clientLabel}</span>
<span className="ml-auto whitespace-nowrap tabular-nums">
{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}} / 기타 {{other}} / 사용자 {{subjects}}",
"로그인 {{login}} / 사용자 {{subjects}}",
{
login: item.loginRequests.toLocaleString(),
other: item.otherRequests.toLocaleString(),
subjects: item.uniqueSubjects.toLocaleString(),
},
)}
@@ -496,9 +487,20 @@ function RPUsageMixedChart({
}
function DashboardPage() {
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,
@@ -520,6 +522,17 @@ function DashboardPage() {
});
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],
@@ -607,6 +620,59 @@ function DashboardPage() {
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.dev.nav.overview", "개요")}
</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-end justify-between gap-4">
@@ -730,7 +796,7 @@ function DashboardPage() {
)}
</section>
<div className="grid gap-4 lg:grid-cols-[1.1fr,0.9fr]">
<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" />
@@ -744,10 +810,10 @@ function DashboardPage() {
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.dev.dashboard.distribution.description",
"애플리케이션 유형 headless login 사용 현황을 빠르게 확인합니다.",
"애플리케이션 유형 분포와 server side app의 headless login 사용 현황을 빠르게 확인합니다.",
)}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<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")}
@@ -755,6 +821,15 @@ function DashboardPage() {
<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">
@@ -764,14 +839,6 @@ function DashboardPage() {
{distribution.pkceClients.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.headless", "Headless Login")}
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{distribution.headlessClients.toLocaleString()}
</p>
</div>
</div>
</section>
@@ -803,7 +870,7 @@ function DashboardPage() {
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-medium">
<p className="truncate font-semibold">
{client.name || t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="truncate text-xs text-muted-foreground">
@@ -828,6 +895,10 @@ function DashboardPage() {
? t("ui.dev.clients.status.inactive", "비활성")
: client.status || "-"}
</p>
<p>
{t("ui.dev.clients.table.created_at", "생성일")}{" "}
{formatDate(client.createdAt)}
</p>
</div>
</div>
))

View File

@@ -500,6 +500,10 @@ openid = "Openid"
profile = "Profile"
[msg.dev.dashboard]
access_denied = "Overview is available only to users with developer access."
access_denied_detail = "Submit a request on the developer access page and wait for approval."
access_pending = "Your developer access request is under review."
access_pending_detail = "You can use the overview and developer features after a super admin approves it."
description = "View connected application composition and authentication operations metrics in one place."
[msg.dev.dashboard.hero]
@@ -1664,7 +1668,7 @@ registry = "RP registry"
rp_synced = "RP registry synced"
[ui.dev.dashboard.distribution]
headless = "Headless Login"
headless_hint = "{{count}} with Headless Login enabled"
pkce = "PKCE"
private = "Server side App"
title = "Application Distribution"
@@ -1672,13 +1676,13 @@ title = "Application Distribution"
[ui.dev.dashboard.chart]
aria = "RP request overview"
filter_all = "All"
login_requests = "Login requests"
other_requests = "Other requests"
period_day = "Day"
period_month = "Month"
period_week = "Week"
series = "Login {{login}} / Other {{other}} / Users {{subjects}}"
title = "Login and other requests by application"
series = "Login {{login}} / Users {{subjects}}"
title = "Login requests by application"
x_axis = "X-axis: Period"
y_axis = "Y-axis: Login requests"
[ui.dev.dashboard.next]
subtitle = "Ship the RP controls"

View File

@@ -500,6 +500,10 @@ openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근"
[msg.dev.dashboard]
access_denied = "개요는 개발자 권한이 있어야 볼 수 있습니다."
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
access_pending = "개발자 권한 신청을 검토 중입니다."
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
[msg.dev.dashboard.hero]
@@ -1663,7 +1667,7 @@ registry = "RP registry"
rp_synced = "RP registry synced"
[ui.dev.dashboard.distribution]
headless = "Headless Login"
headless_hint = "이 중 Headless Login 사용 {{count}}"
pkce = "PKCE"
private = "Server side App"
title = "애플리케이션 구성 요약"
@@ -1671,13 +1675,13 @@ title = "애플리케이션 구성 요약"
[ui.dev.dashboard.chart]
aria = "RP 요청 현황"
filter_all = "전체"
login_requests = "로그인 요청"
other_requests = "기타 요청"
period_day = "일"
period_month = "월"
period_week = "주"
series = "로그인 {{login}} / 기타 {{other}} / 사용자 {{subjects}}"
title = "애플리케이션별 로그인요청/기타 요청 현황"
series = "로그인 {{login}} / 사용자 {{subjects}}"
title = "애플리케이션별 로그인 요청 현황"
x_axis = "X축: 기간"
y_axis = "Y축: 로그인 요청 수"
[ui.dev.dashboard.next]
subtitle = "Ship the RP controls"

View File

@@ -538,6 +538,10 @@ openid = ""
profile = ""
[msg.dev.dashboard]
access_denied = ""
access_denied_detail = ""
access_pending = ""
access_pending_detail = ""
description = ""
[msg.dev.dashboard.hero]
@@ -1720,7 +1724,7 @@ registry = ""
rp_synced = ""
[ui.dev.dashboard.distribution]
headless = ""
headless_hint = ""
pkce = ""
private = ""
title = ""
@@ -1728,13 +1732,13 @@ title = ""
[ui.dev.dashboard.chart]
aria = ""
filter_all = ""
login_requests = ""
other_requests = ""
period_day = ""
period_month = ""
period_week = ""
series = ""
title = ""
x_axis = ""
y_axis = ""
[ui.dev.dashboard.next]
subtitle = ""

View File

@@ -435,6 +435,8 @@ services:
- "${ADMINFRONT_PORT:-5173}:5173"
volumes:
- ./adminfront:/app
- ./common:/common
- ./locales:/locales
- /app/node_modules
networks:
- baron_net
@@ -459,6 +461,8 @@ services:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:
- ./devfront:/app
- ./common:/common
- ./locales:/locales
- /app/node_modules
networks:
- baron_net
@@ -484,6 +488,8 @@ services:
- "${ORGFRONT_PORT:-5175}:5175"
volumes:
- ./orgfront:/app
- ./common:/common
- ./locales:/locales
- /app/node_modules
networks:
- baron_net

View File

@@ -553,6 +553,10 @@ openid = "Openid"
profile = "Profile"
[msg.dev.dashboard]
access_denied = "Overview is available only to users with developer access."
access_denied_detail = "Submit a request on the developer access page and wait for approval."
access_pending = "Your developer access request is under review."
access_pending_detail = "You can use the overview and developer features after a super admin approves it."
description = "Review RP composition and authentication operations in one place."
[msg.dev.dashboard.hero]
@@ -2210,21 +2214,21 @@ registry = "RP registry"
rp_synced = "RP registry synced"
[ui.dev.dashboard.distribution]
headless = "Headless Login"
headless_hint = "{{count}} with Headless Login enabled"
pkce = "PKCE"
private = "Server side App"
title = "Application Distribution"
[ui.dev.dashboard.chart]
x_axis = "X-axis: Period"
y_axis = "Y-axis: Login requests"
aria = "RP request overview"
filter_all = "All"
login_requests = "Login requests"
other_requests = "Other requests"
period_day = "Day"
period_month = "Month"
period_week = "Week"
series = "Login {{login}} / Other {{other}} / Users {{subjects}}"
title = "Login and other requests by application"
series = "Login {{login}} / Users {{subjects}}"
title = "Login requests by application"
[ui.dev.dashboard.next]
subtitle = "Ship the RP controls"
@@ -2267,6 +2271,7 @@ plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.dev.nav]
overview = "Overview"
clients = "Connected Application"
logout = "Logout"
developer_request = "Developer Access Request"

View File

@@ -405,6 +405,7 @@ pending = "준비 중"
success = "성공"
[ui.dev.nav]
overview = "개요"
clients = "연동 앱"
logout = "로그아웃"
developer_request = "개발자 권한 신청"
@@ -1044,6 +1045,10 @@ openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근"
[msg.dev.dashboard]
access_denied = "개요는 개발자 권한이 있어야 볼 수 있습니다."
access_denied_detail = "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요."
access_pending = "개발자 권한 신청을 검토 중입니다."
access_pending_detail = "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다."
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
[msg.dev.dashboard.hero]
@@ -2672,21 +2677,21 @@ registry = "RP registry"
rp_synced = "RP registry synced"
[ui.dev.dashboard.distribution]
headless = "Headless Login"
headless_hint = "이 중 Headless Login 사용 {{count}}"
pkce = "PKCE"
private = "Server side App"
title = "애플리케이션 구성 요약"
[ui.dev.dashboard.chart]
x_axis = "X축: 기간"
y_axis = "Y축: 로그인 요청 수"
aria = "RP 요청 현황"
filter_all = "전체"
login_requests = "로그인 요청"
other_requests = "기타 요청"
period_day = "일"
period_month = "월"
period_week = "주"
series = "로그인 {{login}} / 기타 {{other}} / 사용자 {{subjects}}"
title = "애플리케이션별 로그인요청/기타 요청 현황"
series = "로그인 {{login}} / 사용자 {{subjects}}"
title = "애플리케이션별 로그인 요청 현황"
[ui.dev.dashboard.next]
subtitle = "Ship the RP controls"

View File

@@ -908,6 +908,10 @@ openid = ""
profile = ""
[msg.dev.dashboard]
access_denied = ""
access_denied_detail = ""
access_pending = ""
access_pending_detail = ""
description = ""
[msg.dev.dashboard.hero]
@@ -2551,16 +2555,16 @@ registry = ""
rp_synced = ""
[ui.dev.dashboard.distribution]
headless = ""
headless_hint = ""
pkce = ""
private = ""
title = ""
[ui.dev.dashboard.chart]
x_axis = ""
y_axis = ""
aria = ""
filter_all = ""
login_requests = ""
other_requests = ""
period_day = ""
period_month = ""
period_week = ""
@@ -2608,6 +2612,7 @@ plane = ""
subtitle = ""
[ui.dev.nav]
overview = ""
clients = ""
logout = ""