diff --git a/devfront/src/features/dashboard/DashboardPage.tsx b/devfront/src/features/dashboard/DashboardPage.tsx
index 45aebc80..468cd347 100644
--- a/devfront/src/features/dashboard/DashboardPage.tsx
+++ b/devfront/src/features/dashboard/DashboardPage.tsx
@@ -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;
@@ -141,8 +145,7 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
right.loginRequests +
right.otherRequests -
(left.loginRequests + left.otherRequests),
- )
- .slice(0, 5);
+ );
}
function buildMultiLineSeries(rows: RPUsageDailyMetric[]): MultiLineSeries[] {
@@ -222,6 +225,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 +294,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 +336,6 @@ function RPUsageMixedChart({
className="h-[235px] min-w-[720px] w-full"
>
{t("ui.dev.dashboard.chart.aria", "RP 요청 현황")}
-
-
-
- {t("ui.dev.dashboard.chart.other_requests", "기타 요청")}
-
- {!multiLinePoints || multiLinePoints.length === 0 ? (
- <>
-
-
- {t("ui.dev.dashboard.chart.login_requests", "로그인 요청")}
-
- >
- ) : null}
-
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
@@ -386,18 +363,8 @@ function RPUsageMixedChart({
})}
{daily.map((point, index) => {
const center = x(index);
- const otherHeight = (point.otherRequests / maxValue) * innerHeight;
return (
-
+
+
+ {t("ui.dev.dashboard.chart.x_axis", "X축: 기간")}
+
+
+ {t("ui.dev.dashboard.chart.y_axis", "Y축: 로그인 요청 수")}
+
+
+
{multiLinePoints && multiLinePoints.length > 0 ? (
{multiLinePoints.map((item) => (
-
+
- {item.clientLabel}
+ {item.clientLabel}
+ {seriesByKey.get(item.key) ? (
+
+ {t(
+ "ui.dev.dashboard.chart.series",
+ "로그인 {{login}} / 사용자 {{subjects}}",
+ {
+ login: seriesByKey.get(item.key)!.loginRequests.toLocaleString(),
+ subjects: seriesByKey.get(item.key)!.uniqueSubjects.toLocaleString(),
+ },
+ )}
+
+ ) : null}
))}
- ) : series.length > 0 ? (
+ ) : topSeries.length > 0 ? (
- {series.map((item) => (
-
-
{item.clientLabel}
-
+ {topSeries.map((item) => (
+
+
{item.clientLabel}
+
{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 +489,23 @@ function RPUsageMixedChart({
}
function DashboardPage() {
+ const navigate = useNavigate();
+ const auth = useAuth();
+ const hasAccessToken = Boolean(auth.user?.access_token);
+ const userProfile = auth.user?.profile as Record | undefined;
+ const role = resolveProfileRole(userProfile);
+ const tenantId = userProfile?.tenant_id as string | undefined;
const [period, setPeriod] = useState("day");
const [selectedClientIds, setSelectedClientIds] = useState([]);
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 +527,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 +625,68 @@ function DashboardPage() {
setSelectedClientIds([]);
};
+ if (
+ (role === "user" || role === "tenant_member") &&
+ isLoadingRequestStatus
+ ) {
+ return (
+
+ {t("ui.common.loading", "Loading...")}
+
+ );
+ }
+
+ if (!hasDeveloperAccess) {
+ return (
+
+
+
+ {t("ui.dev.nav.overview", "개요")}
+
+
+ {isDeveloperRequestPending
+ ? t(
+ "msg.dev.dashboard.access_pending",
+ "개발자 권한 신청을 검토 중입니다.",
+ )
+ : t(
+ "msg.dev.dashboard.access_denied",
+ "개요는 개발자 권한이 있어야 볼 수 있습니다.",
+ )}
+
+
+ {isDeveloperRequestPending
+ ? t(
+ "msg.dev.dashboard.access_pending_detail",
+ "super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
+ )
+ : t(
+ "msg.dev.dashboard.access_denied_detail",
+ "개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
+ )}
+
+ {(isDeveloperRequestPending || canRequestDeveloperAccess) && (
+
navigate("/developer-requests")}
+ >
+ {isDeveloperRequestPending
+ ? t(
+ "ui.dev.nav.developer_request",
+ "개발자 권한 신청",
+ )
+ : t(
+ "ui.dev.welcome.btn_request",
+ "개발자 등록 신청하기",
+ )}
+
+ )}
+
+
+ );
+ }
+
return (
@@ -730,7 +810,7 @@ function DashboardPage() {
)}
-
+
@@ -744,10 +824,10 @@ function DashboardPage() {
{t(
"msg.dev.dashboard.distribution.description",
- "애플리케이션 유형과 headless login 사용 현황을 빠르게 확인합니다.",
+ "애플리케이션 유형 분포와 server side app의 headless login 사용 현황을 빠르게 확인합니다.",
)}
-
+
{t("ui.dev.dashboard.distribution.private", "Server side App")}
@@ -755,6 +835,15 @@ function DashboardPage() {
{distribution.privateClients.toLocaleString()}
+
+ {t(
+ "ui.dev.dashboard.distribution.headless_hint",
+ "이 중 Headless Login 사용 {{count}}",
+ {
+ count: distribution.headlessClients.toLocaleString(),
+ },
+ )}
+
@@ -764,14 +853,6 @@ function DashboardPage() {
{distribution.pkceClients.toLocaleString()}
-
-
- {t("ui.dev.dashboard.distribution.headless", "Headless Login")}
-
-
- {distribution.headlessClients.toLocaleString()}
-
-
@@ -803,7 +884,7 @@ function DashboardPage() {
className="flex items-center justify-between gap-3 rounded-lg border border-border/40 px-3 py-2"
>
-
+
{client.name || t("ui.dev.clients.untitled", "Untitled")}
@@ -828,6 +909,10 @@ function DashboardPage() {
? t("ui.dev.clients.status.inactive", "비활성")
: client.status || "-"}
+
+ {t("ui.dev.clients.table.created_at", "생성일")}{" "}
+ {formatDate(client.createdAt)}
+
))
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index 33cbe458..4b3c8b91 100644
--- a/devfront/src/locales/en.toml
+++ b/devfront/src/locales/en.toml
@@ -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"
diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml
index ba69c665..4fc30bf2 100644
--- a/devfront/src/locales/ko.toml
+++ b/devfront/src/locales/ko.toml
@@ -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"
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index 01e5506e..e2eca1fb 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -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 = ""