diff --git a/README.md b/README.md index a9559c83..5b62f029 100644 --- a/README.md +++ b/README.md @@ -541,11 +541,12 @@ KETO_WRITE_URL = "http://keto:4467" ``` ## 🌐 i18n ꡬ쑰 (κ°„λž΅) -- **Source of Truth**: `locales/template.toml`이 전체 ν‚€μ˜ 기쀀이며 `locales/ko.toml`, `locales/en.toml`κ³Ό 항상 λ™κΈ°ν™”ν•©λ‹ˆλ‹€. -- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`μ—μ„œ `t(key, fallback, vars)`둜 μ‚¬μš©ν•˜κ³  TOML을 `?raw`둜 λ‘œλ“œν•©λ‹ˆλ‹€. +- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 ν˜„μž¬ `userfront`와 μ „μ—­ i18n 검증 κΈ°μ€€ λ¦¬μ†ŒμŠ€μž…λ‹ˆλ‹€. +- **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml`은 `ui.common.*`, `msg.common.*` 같은 React 곡톡 문ꡬ λ ˆμ΄μ–΄μž…λ‹ˆλ‹€. +- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`μ—μ„œ `t(key, fallback, vars)`λ₯Ό μ‚¬μš©ν•˜λ©° `common locale -> app locale override` μˆœμ„œλ‘œ TOML을 `?raw` λ‘œλ“œν•©λ‹ˆλ‹€. - **Flutter(User)**: `userfront/lib/i18n.dart`μ—μ„œ `tr(key, fallback, params)` μ‚¬μš©. `locales/*.toml`을 `tools/i18n-scanner/gen-flutter-i18n.js`둜 `userfront/lib/i18n_data.dart`에 사전 μƒμ„±ν•©λ‹ˆλ‹€. - **UserFront 동기화 κ·œμΉ™**: `locales/*.toml`을 μˆ˜μ •ν•œ λ’€μ—λŠ” λ°˜λ“œμ‹œ `./scripts/sync_userfront_locales.sh`λ₯Ό μ‹€ν–‰ν•΄ `userfront/assets/translations/*.toml`κ³Ό λŸ°νƒ€μž„ λ²ˆμ—­ λ¦¬μ†ŒμŠ€λ₯Ό λ™κΈ°ν™”ν•©λ‹ˆλ‹€. -- **검증**: `node tools/i18n-scanner/index.js`둜 μ½”λ“œ-ν‚€-λ‘œμΌ€μΌ 동기화 μƒνƒœλ₯Ό μ κ²€ν•©λ‹ˆλ‹€. +- **검증**: `node tools/i18n-scanner/index.js`둜 `root locales`와 `common/locales`의 μ½”λ“œ-ν‚€-λ‘œμΌ€μΌ 동기화 μƒνƒœλ₯Ό ν•¨κ»˜ μ κ²€ν•©λ‹ˆλ‹€. ## πŸ§ͺ Code Check CI μ›Œν¬ν”Œλ‘œμš° 파일: `.gitea/workflows/code_check.yml` diff --git a/adminfront/src/app/queryClient.ts b/adminfront/src/app/queryClient.ts index 06c9afde..9064efe8 100644 --- a/adminfront/src/app/queryClient.ts +++ b/adminfront/src/app/queryClient.ts @@ -1,11 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; +import { queryClientDefaultOptions } from "../../../common/core/query/queryClient"; + export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - refetchOnWindowFocus: false, - retry: 1, - }, - }, + defaultOptions: queryClientDefaultOptions, }); diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 320361b7..76140dd5 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -26,6 +26,15 @@ import { shouldAttemptSlidingSessionRenew, shouldAttemptUnlimitedSessionRenew, } from "../../lib/sessionSliding"; +import { + applyShellTheme, + buildShellProfileSummary, + buildShellSessionStatus, + readShellSessionExpiryEnabled, + readShellTheme, + shellLayoutClasses, + writeShellSessionExpiryEnabled, +} from "../../../../common/shell"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; @@ -62,15 +71,11 @@ function AppLayout() { const mockRoleOverride = isMockRoleEnabled ? window.localStorage.getItem("X-Mock-Role") : null; - const [theme, setTheme] = useState<"light" | "dark">(() => { - const stored = window.localStorage.getItem("admin_theme"); - return stored === "dark" ? "dark" : "light"; - }); + const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [isProfileOpen, setIsProfileOpen] = useState(false); - const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => { - const stored = window.localStorage.getItem("baron_session_expiry_enabled"); - return stored !== "false"; - }); + const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState( + readShellSessionExpiryEnabled, + ); const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { @@ -214,14 +219,7 @@ function AppLayout() { }, [auth.user]); useEffect(() => { - const root = document.documentElement; - root.classList.remove("light", "dark"); - if (theme === "light") { - root.classList.add("light"); - } else { - root.classList.add("dark"); - } - window.localStorage.setItem("admin_theme", theme); + applyShellTheme(theme); }, [theme]); useEffect(() => { @@ -388,68 +386,26 @@ function AppLayout() { setTheme((prev) => (prev === "light" ? "dark" : "light")); }; - const profileName = - profile?.name?.trim() || - auth.user?.profile.name?.toString().trim() || - auth.user?.profile.preferred_username?.toString().trim() || - t("ui.dev.profile.unknown_name", "Unknown User"); - const profileEmail = - profile?.email?.trim() || - auth.user?.profile.email?.toString().trim() || - t("ui.dev.profile.unknown_email", "unknown@example.com"); - const profileInitial = profileName.charAt(0).toUpperCase(); + const profileSummary = buildShellProfileSummary({ + profileName: + profile?.name || + auth.user?.profile.name?.toString() || + auth.user?.profile.preferred_username?.toString(), + profileEmail: profile?.email || auth.user?.profile.email?.toString(), + fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"), + fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"), + }); const profileRoleKey = mockRoleOverride || profile?.role || "user"; - const expiresAtSec = auth.user?.expires_at; - const remainingMs = - typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null; - const remainingTotalSec = - remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null; - const remainingMinutes = - remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null; - const remainingSeconds = - remainingTotalSec !== null ? remainingTotalSec % 60 : null; - - let sessionToneClass = - "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - let sessionText = t("ui.dev.session.active", "μ„Έμ…˜ ν™œμ„±"); - - if (remainingMs === null) { - sessionToneClass = "border-border bg-card text-muted-foreground"; - sessionText = t("ui.dev.session.unknown", "μ•Œ 수 μ—†μŒ"); - } else if (remainingMs <= 0) { - sessionToneClass = - "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; - sessionText = t("ui.dev.session.expired", "μ„Έμ…˜ 만료"); - } else if ( - remainingMinutes !== null && - remainingSeconds !== null && - remainingMinutes <= 5 - ) { - sessionToneClass = - "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; - sessionText = t( - "ui.dev.session.expiring", - "만료 μž„λ°•: {{minutes}}λΆ„ {{seconds}}초 λ‚¨μŒ", - { - minutes: remainingMinutes, - seconds: remainingSeconds, - }, - ); - } else { - sessionText = t( - "ui.dev.session.remaining", - "만료 μ˜ˆμ •: {{minutes}}λΆ„ {{seconds}}초 λ‚¨μŒ", - { - minutes: remainingMinutes ?? 0, - seconds: remainingSeconds ?? 0, - }, - ); - } + const sessionStatus = buildShellSessionStatus({ + expiresAtSec: auth.user?.expires_at, + nowMs, + t, + }); const handleSessionExpiryToggle = () => { setIsSessionExpiryEnabled((prev) => { const next = !prev; - window.localStorage.setItem("baron_session_expiry_enabled", String(next)); + writeShellSessionExpiryEnabled(next); return next; }); }; @@ -463,11 +419,11 @@ function AppLayout() { } return ( -
- -
-
-
-
+
+
+
+

{t("ui.dev.header.plane", "Dev Plane")}

@@ -421,12 +384,12 @@ function AppLayout() { {t("ui.dev.header.subtitle", "Manage your applications")}
-
+
{isProfileMenuOpen ? ( -
+

{t("ui.dev.profile.menu_title", "Account")}

-
+

- {profileName} + {profileSummary.name}

- {profileEmail} + {profileSummary.email}

@@ -499,7 +459,7 @@ function AppLayout() {
-
+

@@ -507,7 +467,7 @@ function AppLayout() {

{isSessionExpiryEnabled - ? sessionText + ? sessionStatus.text : t( "ui.dev.session.disabled", "Session expiry disabled", @@ -563,7 +523,7 @@ function AppLayout() {

-
+
diff --git a/devfront/src/components/ui/badge.tsx b/devfront/src/components/ui/badge.tsx index 97aa8bf9..d30c5d1c 100644 --- a/devfront/src/components/ui/badge.tsx +++ b/devfront/src/components/ui/badge.tsx @@ -1,39 +1,21 @@ -import { type VariantProps, cva } from "class-variance-authority"; import type * as React from "react"; +import { + type CommonBadgeVariant, + getCommonBadgeClasses, +} from "../../../../common/ui/badge"; import { cn } from "../../lib/utils"; -const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - outline: "text-foreground", - muted: "border-border bg-secondary/60 text-muted-foreground", - success: - "border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300", - warning: - "border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200", - info: "border-transparent bg-blue-500 text-white hover:bg-blue-500/90", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} +export interface BadgeProps extends React.HTMLAttributes { + variant?: CommonBadgeVariant; +} function Badge({ className, variant, ...props }: BadgeProps) { return ( -
+
); } -export { Badge, badgeVariants }; +export { Badge }; diff --git a/devfront/src/components/ui/button.tsx b/devfront/src/components/ui/button.tsx index ee1a84b4..1066cf1b 100644 --- a/devfront/src/components/ui/button.tsx +++ b/devfront/src/components/ui/button.tsx @@ -1,41 +1,16 @@ import { Slot } from "@radix-ui/react-slot"; -import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; +import { + type CommonButtonSize, + type CommonButtonVariant, + getCommonButtonClasses, +} from "../../../../common/ui/button"; import { cn } from "../../lib/utils"; -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - ghost: "hover:bg-accent hover:text-accent-foreground", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - muted: "bg-muted text-muted-foreground hover:bg-muted/80", - }, - size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-6 text-base", - icon: "h-10 w-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { + extends React.ButtonHTMLAttributes { + variant?: CommonButtonVariant; + size?: CommonButtonSize; asChild?: boolean; } @@ -44,7 +19,7 @@ const Button = React.forwardRef( const Comp = asChild ? Slot : "button"; return ( @@ -53,4 +28,4 @@ const Button = React.forwardRef( ); Button.displayName = "Button"; -export { Button, buttonVariants }; +export { Button }; diff --git a/devfront/src/components/ui/card.tsx b/devfront/src/components/ui/card.tsx index 7048cb12..33685c34 100644 --- a/devfront/src/components/ui/card.tsx +++ b/devfront/src/components/ui/card.tsx @@ -1,65 +1,51 @@ import type * as React from "react"; +import { + commonCardClass, + commonCardContentClass, + commonCardDescriptionClass, + commonCardFooterClass, + commonCardHeaderClass, + commonCardTitleClass, +} from "../../../../common/ui/card"; import { cn } from "../../lib/utils"; function Card({ className, ...props }: React.HTMLAttributes) { - return ( -
- ); + return
; } function CardHeader({ className, ...props }: React.HTMLAttributes) { - return ( -
- ); + return
; } function CardTitle({ className, ...props }: React.HTMLAttributes) { - return ( -

- ); + return

; } function CardDescription({ className, ...props }: React.HTMLAttributes) { - return ( -

- ); + return

; } function CardContent({ className, ...props }: React.HTMLAttributes) { - return

; + return
; } function CardFooter({ className, ...props }: React.HTMLAttributes) { - return ( -
- ); + return
; } export { diff --git a/devfront/src/components/ui/input.tsx b/devfront/src/components/ui/input.tsx index 41955477..1322da14 100644 --- a/devfront/src/components/ui/input.tsx +++ b/devfront/src/components/ui/input.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { commonInputClass } from "../../../../common/ui/input"; import { cn } from "../../lib/utils"; export interface InputProps @@ -9,10 +10,7 @@ const Input = React.forwardRef( return ( diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 5657fb42..78f670b0 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,9 +1,24 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { BookOpenText, Filter, Plus, Search, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + BookOpenText, + Filter, + Plus, + Search, + X, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; +import { + sortItems, + toggleSort, + type SortConfig, + type SortResolverMap, +} from "../../../../common/core/utils"; import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { Avatar, @@ -37,6 +52,7 @@ import { fetchDeveloperRequestStatus, fetchMyTenants, requestDeveloperAccess, + type ClientSummary, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; @@ -44,6 +60,8 @@ import { cn } from "../../lib/utils"; import { fetchMe } from "../auth/authApi"; import { ClientLogo } from "./components/ClientLogo"; +type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt"; + function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); @@ -104,19 +122,48 @@ function ClientsPage() { const [statusFilter, setStatusFilter] = useState("all"); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); + const [sortConfig, setSortConfig] = + useState | null>(null); const clients = data?.items || []; + const clientSortResolvers = useMemo< + SortResolverMap + >( + () => ({ + application: (client) => client.name || client.id, + id: (client) => client.id, + type: (client) => + client.metadata?.headless_login_enabled + ? "private-headless" + : client.type, + status: (client) => client.status, + createdAt: (client) => + client.createdAt ? new Date(client.createdAt) : null, + }), + [], + ); - 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 filteredClients = useMemo(() => { + const nextClients = 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; + }); + + return sortItems(nextClients, sortConfig, clientSortResolvers); + }, [ + clientSortResolvers, + clients, + searchQuery, + sortConfig, + statusFilter, + typeFilter, + ]); const totalClients = statsData?.total_clients ?? clients.length; const activeSessions = statsData?.active_sessions ?? 0; @@ -179,6 +226,22 @@ function ClientsPage() { const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest; + const requestSort = (key: ClientSortKey) => { + setSortConfig((current) => toggleSort(current, key)); + }; + + const getSortIcon = (key: ClientSortKey) => { + if (!sortConfig || sortConfig.key !== key) { + return ; + } + + return sortConfig.direction === "asc" ? ( + + ) : ( + + ); + }; + if (auth.isLoading || !hasAccessToken || isLoading) { return (
@@ -389,18 +452,50 @@ function ClientsPage() { - - {t("ui.dev.clients.table.application", "μ• ν”Œλ¦¬μΌ€μ΄μ…˜")} + requestSort("application")} + > +
+ {t("ui.dev.clients.table.application", "μ• ν”Œλ¦¬μΌ€μ΄μ…˜")} + {getSortIcon("application")} +
- - {t("ui.dev.clients.table.client_id", "Client ID")} + requestSort("id")} + > +
+ {t("ui.dev.clients.table.client_id", "Client ID")} + {getSortIcon("id")} +
- {t("ui.dev.clients.table.type", "μœ ν˜•")} - - {t("ui.dev.clients.table.status", "μƒνƒœ")} + requestSort("type")} + > +
+ {t("ui.dev.clients.table.type", "μœ ν˜•")} + {getSortIcon("type")} +
- - {t("ui.dev.clients.table.created_at", "생성일")} + requestSort("status")} + > +
+ {t("ui.dev.clients.table.status", "μƒνƒœ")} + {getSortIcon("status")} +
+
+ requestSort("createdAt")} + > +
+ {t("ui.dev.clients.table.created_at", "생성일")} + {getSortIcon("createdAt")} +
{t("ui.dev.clients.table.actions", "μ•‘μ…˜")} diff --git a/devfront/src/features/dashboard/DashboardPage.tsx b/devfront/src/features/dashboard/DashboardPage.tsx index 077c8f5d..45aebc80 100644 --- a/devfront/src/features/dashboard/DashboardPage.tsx +++ b/devfront/src/features/dashboard/DashboardPage.tsx @@ -1,292 +1,840 @@ +import { useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; import { Activity, - ArrowRight, + AlertTriangle, BarChart3, CheckCircle2, - Database, - KeyRound, + Layers3, ShieldCheck, - Sparkles, } from "lucide-react"; +import { type ReactNode, useMemo, useState } from "react"; +import { + type ClientSummary, + fetchClients, + fetchDevRPUsageDaily, + fetchDevStats, + type RPUsageDailyMetric, + type RPUsagePeriod, +} from "../../lib/devApi"; 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", - }, +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" }, ]; -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 연동을 μœ„ν•œ ν”„λ‘μ‹œ μ—”λ“œν¬μΈνŠΈ μ€€λΉ„.", - }, -]; +function buildClientDistribution(clients: ClientSummary[]): ClientDistribution { + return clients.reduce( + (summary, client) => { + if (client.status === "active") { + summary.activeClients += 1; + } -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: "κΆŒν•œ κ°€λ“œ 및 감사 둜그 연동", - }, -]; + 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(); + 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(); + 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), + ) + .slice(0, 5); +} + +function buildMultiLineSeries(rows: RPUsageDailyMetric[]): MultiLineSeries[] { + const dates = summarizeDaily(rows).map((point) => point.date); + const byClient = new Map< + string, + { + clientLabel: string; + byDate: Map; + } + >(); + + for (const row of rows) { + const current = byClient.get(row.clientId) ?? { + clientLabel: row.clientName || row.clientId, + byDate: new Map(), + }; + 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 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 OverviewMetric({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( + + {icon} + {label} + {value} + + ); +} + +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 chartWidth = 720; + const chartHeight = 230; + const padX = 48; + const padTop = 32; + 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 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; + 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 ( +
+ {t("msg.dev.dashboard.chart.empty", "ν‘œμ‹œν•  RP 이용 집계가 μ—†μŠ΅λ‹ˆλ‹€.")} +
+ ); + } + + return ( +
+
+ + {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)); + return ( + + + + {label} + + + ); + })} + {daily.map((point, index) => { + const center = x(index); + const otherHeight = (point.otherRequests / maxValue) * innerHeight; + return ( + + + + {formatPeriodLabel(point.date, period)} + + + ); + })} + {!multiLinePoints || multiLinePoints.length === 0 ? ( + <> + + {daily.map((point, index) => ( + + ))} + + ) : ( + multiLinePoints.map((seriesItem) => ( + + + {seriesItem.points.map((point, index) => ( + + ))} + + )) + )} + +
+ + {multiLinePoints && multiLinePoints.length > 0 ? ( +
+ {multiLinePoints.map((item) => ( +
+ + {item.clientLabel} +
+ ))} +
+ ) : series.length > 0 ? ( +
+ {series.map((item) => ( +
+ {item.clientLabel} + + {t( + "ui.dev.dashboard.chart.series", + "둜그인 {{login}} / 기타 {{other}} / μ‚¬μš©μž {{subjects}}", + { + login: item.loginRequests.toLocaleString(), + other: item.otherRequests.toLocaleString(), + subjects: item.uniqueSubjects.toLocaleString(), + }, + )} + +
+ ))} +
+ ) : null} +
+ ); +} function DashboardPage() { + const [period, setPeriod] = useState("day"); + const [selectedClientIds, setSelectedClientIds] = useState([]); + const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; + 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 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( + () => + [...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([]); + }; + return ( -
-
-
-
-
-
- - {t("ui.dev.dashboard.ready_badge", "devfront ready")} -
-

- {t( - "msg.dev.dashboard.hero.title_prefix", - "RP 등둝 ν˜„ν™©κ³Ό Consent μƒνƒœλ₯Ό", - )} - - {t("msg.dev.dashboard.hero.title_emphasis", " ν•˜λ‚˜μ˜ ν™”λ©΄")} - - {t("msg.dev.dashboard.hero.title_suffix", "μ—μ„œ κ΄€λ¦¬ν•©λ‹ˆλ‹€.")} -

-

- {t( - "msg.dev.dashboard.hero.body", - "Hydra Admin API와 λ™κΈ°ν™”λœ RP λͺ©λ‘, μƒνƒœ ν† κΈ€, Consent νšŒμˆ˜κΉŒμ§€ devfrontμ—μ„œ μ²˜λ¦¬ν•˜λ„λ‘ μ€€λΉ„ν•©λ‹ˆλ‹€.", - )} -

-
- - {t("ui.dev.dashboard.badge.rp_synced", "RP registry synced")} - - - {t( - "ui.dev.dashboard.badge.consent_guard", - "Consent guard ready", - )} - - - {t( - "ui.dev.dashboard.badge.policy_toggle", - "Policy toggle enabled", - )} - -
-
-
-
- - {t( - "msg.dev.dashboard.notice.dev_scope", - "RP 정책은 dev scopeμ—μ„œλ§Œ 적용", - )} -
-
- - {t( - "msg.dev.dashboard.notice.consent_audit", - "Consent νšŒμˆ˜λŠ” 감사 λ‘œκ·Έμ™€ 연계", - )} -
-
- - {t( - "msg.dev.dashboard.notice.hydra_health", - "Hydra Admin μƒνƒœ 체크 μ€€λΉ„", - )} -
-
-
-
- -
- {guardHighlights.map((item) => ( -
-
-
-
- {t(item.metricKey, item.metricFallback)} -
- - {t("ui.common.status.active", "active")} - -
-
-

- {t(item.titleKey, item.titleFallback)} -

-

- {t(item.bodyKey, item.bodyFallback)} -

-
-
- ))} -
- -
-
-
-
-

- {t("ui.dev.dashboard.stack.title", "Stack readiness")} -

-

- {t("ui.dev.dashboard.stack.subtitle", "Devfront baseline")} -

-
- -
-
- {stackReadiness.map((item) => ( -
- -

{t(item.key, item.fallback)}

-
- ))} -
-
- -
-

- {t("ui.dev.dashboard.next.title", "Next actions")} +

+
+
+

+ {t("ui.dev.dashboard.title", "Dashboard")} +

+

+ {t( + "msg.dev.dashboard.description", + "연동 μ•± ꡬ성과 인증 운영 μ§€ν‘œλ₯Ό ν•œ κ³³μ—μ„œ ν™•μΈν•©λ‹ˆλ‹€.", + )}

-

- {t("ui.dev.dashboard.next.subtitle", "Ship the RP controls")} -

-
- {nextSteps.map((item, idx) => ( -
+
+ +
+ } + label={t("ui.dev.dashboard.summary.total_clients", "총 RP 수")} + value={formatMetric(stats?.total_clients ?? clients.length)} + /> + } + label={t("ui.dev.dashboard.summary.active_clients", "ν™œμ„± RP 수")} + value={formatMetric(distribution.activeClients)} + /> + } + label={t("ui.dev.dashboard.summary.active_sessions", "ν™œμ„± μ„Έμ…˜ 수")} + value={formatMetric(stats?.active_sessions)} + /> + } + label={t( + "ui.dev.dashboard.summary.auth_failures_24h", + "24μ‹œκ°„ 인증 μ‹€νŒ¨ 수", + )} + value={formatMetric(stats?.auth_failures_24h)} + /> +
+ +
+
+
+ +
+

+ {t( + "ui.dev.dashboard.chart.title", + "μ• ν”Œλ¦¬μΌ€μ΄μ…˜λ³„ λ‘œκ·ΈμΈμš”μ²­/기타 μš”μ²­ ν˜„ν™©", + )} +

+

+ {t( + "msg.dev.dashboard.chart.filter_description", + "전체 λ˜λŠ” μ„ νƒν•œ μ• ν”Œλ¦¬μΌ€μ΄μ…˜λ§Œ κΈ°μ€€μœΌλ‘œ κ·Έλž˜ν”„λ₯Ό ν™•μΈν•©λ‹ˆλ‹€.", + )} +

+
+
+
+ {[ + ["day", t("ui.dev.dashboard.chart.period_day", "일")], + ["week", t("ui.dev.dashboard.chart.period_week", "μ£Ό")], + ["month", t("ui.dev.dashboard.chart.period_month", "μ›”")], + ].map(([value, label]) => ( +
+ {label} + ))}
+ +
+ + {clientFilterOptions.map((client) => ( + + ))} +
+ + {usageQuery.isError ? ( +
{usageErrorText}
+ ) : isAllClientsSelected ? ( + + ) : ( + + )}
-
-
-
-

- {t("ui.dev.dashboard.ops.title", "Ops board")} -

-

- {t("ui.dev.dashboard.ops.subtitle", "ν˜„μž¬ κ΄€μΈ‘")} +
+
+
+ +

+ {t( + "ui.dev.dashboard.distribution.title", + "μ• ν”Œλ¦¬μΌ€μ΄μ…˜ ꡬ성 μš”μ•½", + )}

-
- - {t("ui.dev.dashboard.ops.tag.consent", "Consent grants")} - - - {t("ui.dev.dashboard.ops.tag.rp_status", "RP status")} - -
-
-
-
-
- - {t("ui.dev.dashboard.ops.card.rp_requests", "RP μš”μ²­ 좔이")} +

+ {t( + "msg.dev.dashboard.distribution.description", + "μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μœ ν˜•κ³Ό headless login μ‚¬μš© ν˜„ν™©μ„ λΉ λ₯΄κ²Œ ν™•μΈν•©λ‹ˆλ‹€.", + )} +

+
+
+

+ {t("ui.dev.dashboard.distribution.private", "Server side App")} +

+

+ {distribution.privateClients.toLocaleString()} +

-

- {t("ui.common.status.pending", "μ€€λΉ„ 쀑")} -

-
-
-
- - {t( - "ui.dev.dashboard.ops.card.consent_revoked", - "Consent 회수 건수", - )} +
+

+ {t("ui.dev.dashboard.distribution.pkce", "PKCE")} +

+

+ {distribution.pkceClients.toLocaleString()} +

-

- {t("ui.common.status.pending", "μ€€λΉ„ 쀑")} -

-
-
-
- - {t("ui.dev.dashboard.ops.card.hydra_status", "Hydra μƒνƒœ")} +
+

+ {t("ui.dev.dashboard.distribution.headless", "Headless Login")} +

+

+ {distribution.headlessClients.toLocaleString()} +

-

- {t("ui.common.status.ok", "정상")} -

-
-

+ + +
+
+ +

+ {t("ui.dev.dashboard.recent.title", "λ‚΄ μ• ν”Œλ¦¬μΌ€μ΄μ…˜")} +

+
+

+ {t( + "msg.dev.dashboard.recent.empty", + "ν˜„μž¬ 계정이 μ ‘κ·Όν•  수 μžˆλŠ” RPλ₯Ό ν™•μΈν•©λ‹ˆλ‹€.", + )} +

+
+ {visibleClients.length === 0 ? ( +

+ {t( + "msg.dev.dashboard.recent.none", + "ν‘œμ‹œν•  연동 앱이 μ—†μŠ΅λ‹ˆλ‹€.", + )} +

+ ) : ( + visibleClients.map((client) => ( +
+
+

+ {client.name || t("ui.dev.clients.untitled", "Untitled")} +

+

+ {client.id} +

+
+
+

+ {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")} +

+

+ {client.status === "active" + ? t("ui.dev.clients.status.active", "ν™œμ„±") + : client.status === "inactive" + ? t("ui.dev.clients.status.inactive", "λΉ„ν™œμ„±") + : client.status || "-"} +

+
+
+ )) + )} +
+
+
); } diff --git a/devfront/src/index.css b/devfront/src/index.css index 20ef470f..75a96755 100644 --- a/devfront/src/index.css +++ b/devfront/src/index.css @@ -1,3 +1,5 @@ +@import "../../common/theme/base.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -24,37 +26,7 @@ --input: 215 25% 24%; --ring: 209 79% 52%; --radius: 0.75rem; - } - - .light { - --background: 0 0% 98%; - --foreground: 223 25% 12%; - --card: 0 0% 100%; - --card-foreground: 223 25% 12%; - --popover: 0 0% 100%; - --popover-foreground: 223 25% 12%; - --primary: 209 79% 52%; - --primary-foreground: 0 0% 100%; - --secondary: 220 17% 94%; - --secondary-foreground: 223 25% 20%; - --muted: 223 15% 45%; - --muted-foreground: 223 15% 45%; - --accent: 40 96% 62%; - --accent-foreground: 223 25% 12%; - --destructive: 0 84% 60%; - --destructive-foreground: 0 0% 100%; - --border: 220 17% 90%; - --input: 220 17% 90%; - --ring: 209 79% 52%; - } - - * { - @apply border-border; - } - - body { - @apply min-h-screen bg-background font-sans text-foreground antialiased; - background-image: radial-gradient( + --app-background-image: radial-gradient( circle at 10% 18%, rgba(54, 211, 153, 0.16), transparent 28% @@ -70,14 +42,4 @@ transparent 30% ); } - - a { - @apply text-inherit no-underline; - } -} - -@layer components { - .glass-panel { - @apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur; - } } diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index 815d5c32..62768900 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -1,32 +1,23 @@ import { UserManager, WebStorageStateStore } from "oidc-client-ts"; import type { AuthProviderProps } from "react-oidc-context"; import { - buildDevFrontAuthRedirectUris, - resolveDevFrontPublicOrigin, -} from "./authConfig"; + buildCommonOidcRuntimeConfig, + buildCommonUserManagerSettings, +} from "../../../common/core/auth"; +import { resolveDevFrontPublicOrigin } from "./authConfig"; const devFrontPublicOrigin = resolveDevFrontPublicOrigin( import.meta.env.VITE_DEVFRONT_PUBLIC_URL, window.location.origin, ); -const devFrontRedirectUris = - buildDevFrontAuthRedirectUris(devFrontPublicOrigin); -export const oidcConfig: AuthProviderProps = { - authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL - client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront", - redirect_uri: devFrontRedirectUris.redirectUri, - response_type: "code", - scope: "openid offline_access profile email", // offline_access for refresh token - post_logout_redirect_uri: devFrontRedirectUris.postLogoutRedirectUri, - popup_redirect_uri: devFrontRedirectUris.popupRedirectUri, +export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({ + authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", + clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront", + origin: devFrontPublicOrigin, userStore: new WebStorageStateStore({ store: window.localStorage }), - automaticSilentRenew: false, -}; - -export const userManager = new UserManager({ - ...oidcConfig, - authority: oidcConfig.authority || "", - client_id: oidcConfig.client_id || "", - redirect_uri: oidcConfig.redirect_uri || "", }); + +export const userManager = new UserManager( + buildCommonUserManagerSettings(oidcConfig), +); diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index f1422f59..502f8f82 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -53,6 +53,27 @@ export type DevStats = { auth_failures_24h: number; }; +export type RPUsageDailyMetric = { + date: string; + tenantId: string; + tenantType: string; + tenantName?: string; + clientId: string; + clientName: string; + loginRequests: number; + otherRequests: number; + uniqueSubjects: number; +}; + +export type RPUsagePeriod = "day" | "week" | "month"; + +export type RPUsageDailyResponse = { + items: RPUsageDailyMetric[]; + days: number; + period: RPUsagePeriod; + tenantId?: string; +}; + export type DevAuditLog = { event_id: string; timestamp: string; @@ -214,6 +235,22 @@ export async function fetchDevStats() { return data; } +export async function fetchDevRPUsageDaily({ + days = 14, + period = "day", +}: { + days?: number; + period?: RPUsagePeriod; +} = {}) { + const { data } = await apiClient.get( + "/dev/rp-usage/daily", + { + params: { days, period }, + }, + ); + return data; +} + export async function fetchTenants( limit = 1000, offset = 0, diff --git a/devfront/src/lib/i18n.ts b/devfront/src/lib/i18n.ts index ee5265e1..91b55a98 100644 --- a/devfront/src/lib/i18n.ts +++ b/devfront/src/lib/i18n.ts @@ -1,149 +1,16 @@ -const LOCALE_STORAGE_KEY = "locale"; -const DEFAULT_LOCALE = "ko"; -const SUPPORTED_LOCALES = ["ko", "en"] as const; - -type Locale = (typeof SUPPORTED_LOCALES)[number]; - -type TomlValue = string | TomlObject; - -interface TomlObject { - [key: string]: TomlValue; -} - -function isSupportedLocale(value: string): value is Locale { - return (SUPPORTED_LOCALES as readonly string[]).includes(value); -} - -function parseToml(raw: string): TomlObject { - const lines = raw.split(/\r?\n/); - const root: TomlObject = {}; - let currentPath: string[] = []; - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - - if (line.startsWith("[") && line.endsWith("]")) { - const sectionName = line.slice(1, -1).trim(); - currentPath = sectionName - ? sectionName - .split(".") - .map((part) => part.trim()) - .filter(Boolean) - : []; - continue; - } - - const eqIndex = line.indexOf("="); - if (eqIndex === -1) { - continue; - } - - const key = line.slice(0, eqIndex).trim(); - const valueRaw = line.slice(eqIndex + 1).trim(); - if (!key) { - continue; - } - - let value = valueRaw; - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - let cursor: TomlObject = root; - for (const section of currentPath) { - if (!cursor[section] || typeof cursor[section] === "string") { - cursor[section] = {}; - } - cursor = cursor[section] as TomlObject; - } - - cursor[key] = value; - } - - return root; -} - -function getValue(target: TomlObject, key: string): string | undefined { - const parts = key.split("."); - let cursor: TomlValue = target; - for (const part of parts) { - if (typeof cursor !== "object" || cursor === null) { - return undefined; - } - cursor = (cursor as TomlObject)[part]; - if (cursor === undefined) { - return undefined; - } - } - return typeof cursor === "string" ? cursor : undefined; -} - -function detectLocale(): Locale { - if (typeof window === "undefined") { - return DEFAULT_LOCALE; - } - - const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY); - if (stored && isSupportedLocale(stored)) { - return stored; - } - - const pathLocale = window.location.pathname.split("/")[1]; - if (pathLocale && isSupportedLocale(pathLocale)) { - return pathLocale; - } - - const browserLang = window.navigator.language.toLowerCase(); - if (browserLang.startsWith("ko")) { - return "ko"; - } - - return DEFAULT_LOCALE; -} +import { createTomlTranslator } from "../../../common/core/i18n"; +// eslint-disable-next-line import/no-unresolved +import commonEnRaw from "../../../common/locales/en.toml?raw"; +// eslint-disable-next-line import/no-unresolved +import commonKoRaw from "../../../common/locales/ko.toml?raw"; // eslint-disable-next-line import/no-unresolved import enRaw from "../locales/en.toml?raw"; // Vite ?raw importλŠ” λŸ°νƒ€μž„ μƒμˆ˜λ‘œ λ²ˆλ“€λ©λ‹ˆλ‹€. // eslint-disable-next-line import/no-unresolved import koRaw from "../locales/ko.toml?raw"; -const translations: Record = { - ko: parseToml(koRaw), - en: parseToml(enRaw), -}; - -function formatTemplate( - template: string, - vars?: Record, -): string { - const normalizedTemplate = template.replace(/\\n/g, "\n"); - if (!vars) { - return normalizedTemplate; - } - return normalizedTemplate.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { - const value = vars[key]; - if (value === undefined || value === null) { - return match; - } - return String(value); - }); -} - -export function t( - key: string, - fallback?: string, - vars?: Record, -): string { - const locale = detectLocale(); - const value = getValue(translations[locale], key); - if (value && value.length > 0) { - return formatTemplate(value, vars); - } - return formatTemplate(fallback ?? key, vars); -} +export const t = createTomlTranslator({ + ko: [commonKoRaw, koRaw], + en: [commonEnRaw, enRaw], +}); diff --git a/devfront/src/lib/sessionSliding.ts b/devfront/src/lib/sessionSliding.ts index 831ef8e8..389fcef1 100644 --- a/devfront/src/lib/sessionSliding.ts +++ b/devfront/src/lib/sessionSliding.ts @@ -1,76 +1,6 @@ -export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000; -export const SESSION_RENEW_THROTTLE_MS = 30 * 1000; - -type SlidingSessionRenewDecisionParams = { - expiresAtSec?: number | null; - nowMs: number; - isEnabled: boolean; - isAuthenticated: boolean; - isLoading: boolean; - isRenewInFlight: boolean; - lastAttemptAtMs: number; - thresholdMs?: number; - throttleMs?: number; -}; - -export function shouldAttemptSlidingSessionRenew({ - expiresAtSec, - nowMs, - isEnabled, - isAuthenticated, - isLoading, - isRenewInFlight, - lastAttemptAtMs, - thresholdMs = SESSION_RENEW_THRESHOLD_MS, - throttleMs = SESSION_RENEW_THROTTLE_MS, -}: SlidingSessionRenewDecisionParams) { - if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { - return false; - } - - if (typeof expiresAtSec !== "number") { - return false; - } - - const remainingMs = expiresAtSec * 1000 - nowMs; - if (remainingMs <= 0 || remainingMs > thresholdMs) { - return false; - } - - if (nowMs - lastAttemptAtMs < throttleMs) { - return false; - } - - return true; -} - -export function shouldAttemptUnlimitedSessionRenew({ - expiresAtSec, - nowMs, - isEnabled, - isAuthenticated, - isLoading, - isRenewInFlight, - lastAttemptAtMs, - thresholdMs = SESSION_RENEW_THRESHOLD_MS, - throttleMs = SESSION_RENEW_THROTTLE_MS, -}: SlidingSessionRenewDecisionParams) { - if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) { - return false; - } - - if (typeof expiresAtSec !== "number") { - return false; - } - - const remainingMs = expiresAtSec * 1000 - nowMs; - if (remainingMs <= 0 || remainingMs > thresholdMs) { - return false; - } - - if (nowMs - lastAttemptAtMs < throttleMs) { - return false; - } - - return true; -} +export { + DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS, + DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS, + shouldAttemptSlidingSessionRenew, + shouldAttemptUnlimitedSessionRenew, +} from "../../../common/core/session"; diff --git a/devfront/src/lib/utils.ts b/devfront/src/lib/utils.ts index 365058ce..8715786b 100644 --- a/devfront/src/lib/utils.ts +++ b/devfront/src/lib/utils.ts @@ -1,6 +1,8 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import { mergeClassNames } from "../../../common/core/utils"; + export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return mergeClassNames(twMerge, [clsx(inputs)]); } diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index ed6ce6a2..33cbe458 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -300,15 +300,6 @@ no_custom = "No custom fields defined for this tenant." [msg.admin.users.list.registry] count = "Count" -[msg.common] -error = "Error" -loading = "Loading..." -no_description = "No Description." -parsing = "Parsing data..." -requesting = "Requesting..." -saving = "Saving..." -unknown_error = "unknown error" - [msg.dev] logout_confirm = "Are you sure you want to log out?" @@ -509,6 +500,7 @@ openid = "Openid" profile = "Profile" [msg.dev.dashboard] +description = "View connected application composition and authentication operations metrics in one place." [msg.dev.dashboard.hero] body = "Body" @@ -516,6 +508,29 @@ title_emphasis = "Title Emphasis" title_prefix = "Title Prefix" title_suffix = "Title Suffix" +[msg.dev.dashboard.distribution] +description = "Quickly review application types and headless login usage." + +[msg.dev.dashboard.chart] +empty = "No RP usage aggregates to display." +filter_description = "View the chart for all applications or only the ones you select." +forbidden = "Your current account does not have permission to view RP usage statistics." +server_error = "A server error occurred while loading RP usage statistics." +service_unavailable = "The RP usage aggregation service is not ready yet." +unavailable = "RP usage statistics API is unavailable. The chart will appear here once aggregate data is ready." +unavailable_with_reason = "RP usage statistics API is unavailable. {{reason}}" + +[msg.dev.dashboard.quick_links] +audit = "Review RP configuration changes and operational history." +clients = "Browse registered RPs and manage their status and type." +description = "Jump directly to key operational screens." +developer_request = "Review developer access requests or submit a new one." +new_client = "Configure redirect URIs, grant types, and authentication methods." + +[msg.dev.dashboard.recent] +empty = "Review the relying parties this account can access." +none = "No connected applications to display." + [msg.dev.dashboard.notice] consent_audit = "Consent Audit" dev_scope = "Dev Scope" @@ -1244,75 +1259,6 @@ name = "Name" role = "Role" -[ui.common] -add = "Add" -all = "All" -admin_only = "Admin Only" -assign = "Assign" -back = "Back" -back_to_login = "Back to login" -cancel = "Cancel" -change_file = "Change File" -clear_search = "Clear Search" -close = "Close" -collapse = "Collapse" -confirm = "Confirm" -copy = "Copy" -create = "Create" -delete = "Delete" -details = "Details" -disabled = "Disabled" -edit = "Edit" -enabled = "Enabled" -export = "Export" -fail = "Fail" -go_home = "Go Home" -view = "View" -hyphen = "-" -manage = "Manage" -na = "N/A" -never = "Never" -next = "Next" -none = "None" -page_of = "Page {{page}} of {{total}}" -prev = "Prev" -previous = "Previous" -qr = "QR" -reset = "Reset" -read_only = "Read Only" -refresh = "Refresh" -remove = "Remove" -resend = "Resend" -retry = "Retry" -save = "Save" -search = "Search" -select = "Select" -select_file = "Select File" -select_placeholder = "Select Placeholder" -show_more = "Show More" -language = "Language" -language_ko = "Korean" -language_en = "English" -success = "Success" -theme_dark = "Dark" -theme_light = "Light" -theme_toggle = "Theme Toggle" -unknown = "Unknown" - -[ui.common.badge] -admin_only = "Admin only" -command_only = "Command only" -system = "System" - -[ui.common.status] -active = "Active" -blocked = "Blocked" -failure = "Failure" -inactive = "Inactive" -ok = "Ok" -pending = "Pending" -success = "Success" - [test] key = "Test" @@ -1330,6 +1276,7 @@ audit_logs = "Audit Logs" clients = "Connected Application" developer_request = "Developer Access Request" logout = "Logout" +overview = "Overview" [ui.dev.audit] load_more = "Load more" @@ -1707,12 +1654,32 @@ private_headless = "Server side App (Headless Login)" [ui.dev.dashboard] ready_badge = "devfront ready" +title = "Dashboard" [ui.dev.dashboard.badge] consent_guard = "Consent guard ready" +oidc = "OIDC operations" policy_toggle = "Policy toggle enabled" +registry = "RP registry" rp_synced = "RP registry synced" +[ui.dev.dashboard.distribution] +headless = "Headless Login" +pkce = "PKCE" +private = "Server side App" +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" + [ui.dev.dashboard.next] subtitle = "Ship the RP controls" title = "Next actions" @@ -1730,11 +1697,25 @@ rp_requests = "Rp Requests" consent = "Consent grants" rp_status = "RP status" +[ui.dev.dashboard.quick_links] +create_button = "Create RP" +new_client = "New RP" +title = "Quick links" + +[ui.dev.dashboard.recent] +title = "My Applications" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" title = "Stack readiness" +[ui.dev.dashboard.summary] +active_clients = "Active RPs" +active_sessions = "Active sessions" +auth_failures_24h = "24h auth failures" +total_clients = "Total RPs" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index c23efa55..ba69c665 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -300,15 +300,6 @@ no_custom = "이 ν…Œλ„ŒνŠΈμ— μ •μ˜λœ μ»€μŠ€ν…€ ν•„λ“œκ°€ μ—†μŠ΅λ‹ˆλ‹€." [msg.admin.users.list.registry] count = "총 {{count}}λͺ…μ˜ μ‚¬μš©μžκ°€ λ“±λ‘λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€." -[msg.common] -error = "였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." -loading = "λ‘œλ”© 쀑..." -no_description = "μ„€λͺ…이 μ—†μŠ΅λ‹ˆλ‹€." -parsing = "데이터 νŒŒμ‹± 쀑..." -requesting = "μš”μ²­ 쀑..." -saving = "μ €μž₯ 쀑..." -unknown_error = "μ•Œ 수 μ—†λŠ” 였λ₯˜" - [msg.dev] logout_confirm = "λ‘œκ·Έμ•„μ›ƒ ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?" @@ -509,6 +500,7 @@ openid = "OIDC 인증 ν•„μˆ˜ μŠ€μ½”ν”„" profile = "κΈ°λ³Έ ν”„λ‘œν•„ 정보 μ ‘κ·Ό" [msg.dev.dashboard] +description = "연동 μ•± ꡬ성과 인증 운영 μ§€ν‘œλ₯Ό ν•œ κ³³μ—μ„œ ν™•μΈν•©λ‹ˆλ‹€." [msg.dev.dashboard.hero] body = "Hydra Admin API와 λ™κΈ°ν™”λœ RP λͺ©λ‘, μƒνƒœ ν† κΈ€, Consent νšŒμˆ˜κΉŒμ§€ devfrontμ—μ„œ μ²˜λ¦¬ν•˜λ„λ‘ μ€€λΉ„ν•©λ‹ˆλ‹€." @@ -516,6 +508,29 @@ title_emphasis = " ν•˜λ‚˜μ˜ ν™”λ©΄" title_prefix = "RP 등둝 ν˜„ν™©κ³Ό Consent μƒνƒœλ₯Ό" title_suffix = "μ—μ„œ κ΄€λ¦¬ν•©λ‹ˆλ‹€." +[msg.dev.dashboard.distribution] +description = "μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μœ ν˜•κ³Ό headless login μ‚¬μš© ν˜„ν™©μ„ λΉ λ₯΄κ²Œ ν™•μΈν•©λ‹ˆλ‹€." + +[msg.dev.dashboard.chart] +empty = "ν‘œμ‹œν•  RP 이용 집계가 μ—†μŠ΅λ‹ˆλ‹€." +filter_description = "전체 λ˜λŠ” μ„ νƒν•œ μ• ν”Œλ¦¬μΌ€μ΄μ…˜λ§Œ κΈ°μ€€μœΌλ‘œ κ·Έλž˜ν”„λ₯Ό ν™•μΈν•©λ‹ˆλ‹€." +forbidden = "ν˜„μž¬ κ³„μ •μ—λŠ” RP 이용 톡계λ₯Ό λ³Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€." +server_error = "RP 이용 톡계 쑰회 쀑 μ„œλ²„ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." +service_unavailable = "RP 이용 톡계 집계 μ„œλΉ„μŠ€κ°€ 아직 μ€€λΉ„λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€." +unavailable = "RP 이용 톡계 API 응닡을 확인할 수 μ—†μŠ΅λ‹ˆλ‹€. 집계 데이터가 μ€€λΉ„λ˜λ©΄ 이 μ˜μ—­μ— κ·Έλž˜ν”„κ°€ ν‘œμ‹œλ©λ‹ˆλ‹€." +unavailable_with_reason = "RP 이용 톡계 API 응닡을 확인할 수 μ—†μŠ΅λ‹ˆλ‹€. {{reason}}" + +[msg.dev.dashboard.quick_links] +audit = "RP μ„€μ • λ³€κ²½κ³Ό 운영 이λ ₯을 ν™•μΈν•©λ‹ˆλ‹€." +clients = "λ“±λ‘λœ RPλ₯Ό μ‘°νšŒν•˜κ³  μƒνƒœμ™€ μœ ν˜•μ„ κ΄€λ¦¬ν•©λ‹ˆλ‹€." +description = "μ£Όμš” 운영 ν™”λ©΄μœΌλ‘œ λ°”λ‘œ μ΄λ™ν•©λ‹ˆλ‹€." +developer_request = "개발자 κΆŒν•œ μ‹ μ²­ 내역을 ν™•μΈν•˜κ±°λ‚˜ μƒˆ μš”μ²­μ„ λ“±λ‘ν•©λ‹ˆλ‹€." +new_client = "redirect URI, grant type, 인증 방식을 μ„€μ •ν•©λ‹ˆλ‹€." + +[msg.dev.dashboard.recent] +empty = "ν˜„μž¬ 계정이 μ ‘κ·Όν•  수 μžˆλŠ” RPλ₯Ό ν™•μΈν•©λ‹ˆλ‹€." +none = "ν‘œμ‹œν•  연동 앱이 μ—†μŠ΅λ‹ˆλ‹€." + [msg.dev.dashboard.notice] consent_audit = "Consent νšŒμˆ˜λŠ” 감사 λ‘œκ·Έμ™€ 연계" dev_scope = "RP 정책은 dev scopeμ—μ„œλ§Œ 적용" @@ -1244,75 +1259,6 @@ name = "이름" role = "μ—­ν• " -[ui.common] -add = "μΆ”κ°€" -all = "전체" -admin_only = "κ΄€λ¦¬μž μ „μš©" -assign = "ν• λ‹Ή" -back = "λŒμ•„κ°€κΈ°" -back_to_login = "둜그인으둜 λŒμ•„κ°€κΈ°" -cancel = "μ·¨μ†Œ" -change_file = "파일 λ³€κ²½" -clear_search = "검색 μ΄ˆκΈ°ν™”" -close = "λ‹«κΈ°" -collapse = "μ ‘κΈ°" -confirm = "확인" -copy = "볡사" -create = "생성" -delete = "μ‚­μ œ" -details = "상세정보" -disabled = "μ‚¬μš© μ•ˆ 함" -edit = "νŽΈμ§‘" -enabled = "μ‚¬μš©" -export = "내보내기" -fail = "μ‹€νŒ¨" -go_home = "ν™ˆμœΌλ‘œ" -view = "보기" -hyphen = "-" -manage = "관리" -na = "N/A" -never = "Never" -next = "λ‹€μŒ" -none = "μ—†μŒ" -page_of = "Page {{page}} of {{total}}" -prev = "이전" -previous = "이전" -qr = "QR" -reset = "μ΄ˆκΈ°ν™”" -read_only = "읽기 μ „μš©" -refresh = "μƒˆλ‘œκ³ μΉ¨" -remove = "μ œμ™Έ" -resend = "μž¬λ°œμ†‘" -retry = "λ‹€μ‹œ μ‹œλ„" -save = "μ €μž₯" -search = "검색" -select = "선택" -select_file = "파일 선택" -select_placeholder = "μ„ νƒν•˜μ„Έμš”" -show_more = "+ 더보기" -language = "μ–Έμ–΄" -language_ko = "ν•œκ΅­μ–΄" -language_en = "English" -success = "성곡" -theme_dark = "Dark" -theme_light = "Light" -theme_toggle = "ν…Œλ§ˆ μ „ν™˜" -unknown = "Unknown" - -[ui.common.badge] -admin_only = "Admin only" -command_only = "Command only" -system = "System" - -[ui.common.status] -active = "ν™œμ„±" -blocked = "차단됨" -failure = "μ‹€νŒ¨" -inactive = "λΉ„ν™œμ„±" -ok = "정상" -pending = "μ€€λΉ„ 쀑" -success = "성곡" - [test] key = "ν…ŒμŠ€νŠΈ" @@ -1330,6 +1276,7 @@ audit_logs = "감사 둜그" clients = "연동 μ•±" developer_request = "개발자 κΆŒν•œ μ‹ μ²­" logout = "λ‘œκ·Έμ•„μ›ƒ" +overview = "κ°œμš”" [ui.dev.audit] load_more = "더 보기" @@ -1706,12 +1653,32 @@ private_headless = "Server side App (Headless Login)" [ui.dev.dashboard] ready_badge = "devfront ready" +title = "λŒ€μ‹œλ³΄λ“œ" [ui.dev.dashboard.badge] consent_guard = "Consent guard ready" +oidc = "OIDC 운영" policy_toggle = "Policy toggle enabled" +registry = "RP registry" rp_synced = "RP registry synced" +[ui.dev.dashboard.distribution] +headless = "Headless Login" +pkce = "PKCE" +private = "Server side App" +title = "μ• ν”Œλ¦¬μΌ€μ΄μ…˜ ꡬ성 μš”μ•½" + +[ui.dev.dashboard.chart] +aria = "RP μš”μ²­ ν˜„ν™©" +filter_all = "전체" +login_requests = "둜그인 μš”μ²­" +other_requests = "기타 μš”μ²­" +period_day = "일" +period_month = "μ›”" +period_week = "μ£Ό" +series = "둜그인 {{login}} / 기타 {{other}} / μ‚¬μš©μž {{subjects}}" +title = "μ• ν”Œλ¦¬μΌ€μ΄μ…˜λ³„ λ‘œκ·ΈμΈμš”μ²­/기타 μš”μ²­ ν˜„ν™©" + [ui.dev.dashboard.next] subtitle = "Ship the RP controls" title = "Next actions" @@ -1729,11 +1696,25 @@ rp_requests = "RP μš”μ²­ 좔이" consent = "Consent grants" rp_status = "RP status" +[ui.dev.dashboard.quick_links] +create_button = "μƒˆ RP λ§Œλ“€κΈ°" +new_client = "μƒˆ RP 생성" +title = "λΉ λ₯Έ 이동" + +[ui.dev.dashboard.recent] +title = "λ‚΄ μ• ν”Œλ¦¬μΌ€μ΄μ…˜" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" title = "Stack readiness" +[ui.dev.dashboard.summary] +active_clients = "ν™œμ„± RP 수" +active_sessions = "ν™œμ„± μ„Έμ…˜ 수" +auth_failures_24h = "24μ‹œκ°„ 인증 μ‹€νŒ¨ 수" +total_clients = "총 RP 수" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 32e7cae4..01e5506e 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -314,15 +314,6 @@ no_custom = "" [msg.admin.users.list.registry] count = "" -[msg.common] -error = "" -loading = "" -no_description = "" -parsing = "" -requesting = "" -saving = "" -unknown_error = "" - [msg.dev] logout_confirm = "" @@ -547,6 +538,7 @@ openid = "" profile = "" [msg.dev.dashboard] +description = "" [msg.dev.dashboard.hero] body = "" @@ -554,6 +546,29 @@ title_emphasis = "" title_prefix = "" title_suffix = "" +[msg.dev.dashboard.distribution] +description = "" + +[msg.dev.dashboard.chart] +empty = "" +filter_description = "" +forbidden = "" +server_error = "" +service_unavailable = "" +unavailable = "" +unavailable_with_reason = "" + +[msg.dev.dashboard.quick_links] +audit = "" +clients = "" +description = "" +developer_request = "" +new_client = "" + +[msg.dev.dashboard.recent] +empty = "" +none = "" + [msg.dev.dashboard.notice] consent_audit = "" dev_scope = "" @@ -1297,75 +1312,6 @@ name = "" role = "" -[ui.common] -add = "" -all = "" -admin_only = "" -assign = "" -back = "" -back_to_login = "" -cancel = "" -change_file = "" -clear_search = "" -close = "" -collapse = "" -confirm = "" -copy = "" -create = "" -delete = "" -details = "" -disabled = "" -edit = "" -enabled = "" -export = "" -fail = "" -go_home = "" -view = "" -hyphen = "" -manage = "" -na = "" -never = "" -next = "" -none = "" -page_of = "" -prev = "" -previous = "" -qr = "" -reset = "" -read_only = "" -refresh = "" -remove = "" -resend = "" -retry = "" -save = "" -search = "" -select = "" -select_file = "" -select_placeholder = "" -show_more = "" -language = "" -language_ko = "" -language_en = "" -success = "" -theme_dark = "" -theme_light = "" -theme_toggle = "" -unknown = "" - -[ui.common.badge] -admin_only = "" -command_only = "" -system = "" - -[ui.common.status] -active = "" -blocked = "" -failure = "" -inactive = "" -ok = "" -pending = "" -success = "" - [test] key = "" @@ -1381,8 +1327,9 @@ scope_badge = "" [ui.dev.nav] audit_logs = "" clients = "" -logout = "" developer_request = "" +logout = "" +overview = "" [ui.dev.welcome] btn_request = "" @@ -1763,12 +1710,32 @@ private_headless = "" [ui.dev.dashboard] ready_badge = "" +title = "" [ui.dev.dashboard.badge] consent_guard = "" +oidc = "" policy_toggle = "" +registry = "" rp_synced = "" +[ui.dev.dashboard.distribution] +headless = "" +pkce = "" +private = "" +title = "" + +[ui.dev.dashboard.chart] +aria = "" +filter_all = "" +login_requests = "" +other_requests = "" +period_day = "" +period_month = "" +period_week = "" +series = "" +title = "" + [ui.dev.dashboard.next] subtitle = "" title = "" @@ -1786,11 +1753,25 @@ rp_requests = "" consent = "" rp_status = "" +[ui.dev.dashboard.quick_links] +create_button = "" +new_client = "" +title = "" + +[ui.dev.dashboard.recent] +title = "" + [ui.dev.dashboard.stack] notes = "" subtitle = "" title = "" +[ui.dev.dashboard.summary] +active_clients = "" +active_sessions = "" +auth_failures_24h = "" +total_clients = "" + [ui.dev.header] plane = "" subtitle = "" diff --git a/devfront/tailwind.config.ts b/devfront/tailwind.config.ts index dfb77657..435c4926 100644 --- a/devfront/tailwind.config.ts +++ b/devfront/tailwind.config.ts @@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme"; const config: Config = { darkMode: ["class"], - content: ["./index.html", "./src/**/*.{ts,tsx}"], + content: [ + "./index.html", + "./src/**/*.{ts,tsx}", + "../common/**/*.{ts,tsx,css}", + ], theme: { container: { center: true, diff --git a/docker-compose.yaml b/docker-compose.yaml index 03bc7874..fee5954c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -62,6 +62,7 @@ services: - "${ADMINFRONT_PORT:-5173}:5173" volumes: - ./adminfront:/app + - ./common:/common - ./locales:/locales - /app/node_modules networks: @@ -82,6 +83,7 @@ services: - "${DEVFRONT_PORT:-5174}:5173" volumes: - ./devfront:/app + - ./common:/common - ./locales:/locales - /app/node_modules networks: @@ -102,6 +104,7 @@ services: - "${ORGFRONT_PORT:-5175}:5175" volumes: - ./orgfront:/app + - ./common:/common - ./locales:/locales - /app/node_modules networks: diff --git a/docs/i18n.md b/docs/i18n.md index 26f0eaba..1361c9cf 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -119,14 +119,18 @@ TOMLμ—μ„œλŠ” `[Section]`을 μ‚¬μš©ν•˜μ—¬ 계측을 ν‘œν˜„ν•©λ‹ˆλ‹€. ./scripts/sync_userfront_locales.sh ``` * 이 단계가 λˆ„λ½λ˜λ©΄ 루트 SoT와 UserFront μ‹€μ œ ν‘œμ‹œ 문ꡬ가 μ–΄κΈ‹λ‚  수 μžˆμŠ΅λ‹ˆλ‹€. +4. **React 곡톡 locale λ ˆμ΄μ–΄**: + * React 계열 ν”„λŸ°νŠΈ(`adminfront`, `devfront`, `orgfront`)λŠ” `common/locales/*.toml`을 곡톡 문ꡬ λ ˆμ΄μ–΄λ‘œ μ‚¬μš©ν•©λ‹ˆλ‹€. + * 곡톡 keyλŠ” `ui.common.*`, `msg.common.*` λ²”μœ„μ—λ§Œ λ‘‘λ‹ˆλ‹€. + * 각 μ•±μ˜ `src/locales/*.toml`은 μ•± μ „μš© 문ꡬλ₯Ό μœ μ§€ν•˜κ³ , λ‘œλ”© μ‹œ `common locale -> app locale override` μˆœμ„œλ‘œ merge ν•©λ‹ˆλ‹€. 3. **CI 검증 (Verification)**: * **Level 1: λ¦¬μ†ŒμŠ€ 동기화 검사 (`template` vs `lang`)** - * `template.toml`에 μžˆλŠ” λͺ¨λ“  ν‚€κ°€ `ko.toml`, `en.toml`에 μ‘΄μž¬ν•˜λŠ”μ§€ μž¬κ·€μ μœΌλ‘œ κ²€μ‚¬ν•©λ‹ˆλ‹€. + * `locales/*.toml`κ³Ό `common/locales/*.toml` 각각에 λŒ€ν•΄ `template.toml`에 μžˆλŠ” λͺ¨λ“  ν‚€κ°€ `ko.toml`, `en.toml`에 μ‘΄μž¬ν•˜λŠ”μ§€ μž¬κ·€μ μœΌλ‘œ κ²€μ‚¬ν•©λ‹ˆλ‹€. * λˆ„λ½ μ‹œ λΉŒλ“œ μ‹€νŒ¨. * **Level 2: μ½”λ“œ μ‚¬μš©μ„± 검사 (`code` vs `template`)** * 전체 ν”„λ‘ νŠΈμ—”λ“œ μ†ŒμŠ€μ½”λ“œ(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)λ₯Ό μŠ€μΊ”ν•˜μ—¬ λ²ˆμ—­ ν•¨μˆ˜(`t('key')`, `'key'.tr()`)에 μ‚¬μš©λœ ν‚€λ₯Ό μΆ”μΆœν•©λ‹ˆλ‹€. - * **Missing Key**: μ½”λ“œμ—λŠ” μžˆλŠ”λ° `template.toml`에 μ—†λŠ” ν‚€λ₯Ό κ²€μΆœν•˜μ—¬ κ²½κ³  λ˜λŠ” μ—λŸ¬λ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. - * **Unused Key**: `template.toml`μ—λŠ” μžˆλŠ”λ° μ½”λ“œ μ–΄λ””μ—μ„œλ„ 쓰이지 μ•ŠλŠ” ν‚€λ₯Ό λ¦¬ν¬νŠΈν•˜μ—¬ 정리할 수 있게 ν•©λ‹ˆλ‹€. + * **Missing Key**: μ½”λ“œμ—λŠ” μžˆλŠ”λ° ν•΄λ‹Ή λ ˆμ΄μ–΄μ˜ `template.toml`에 μ—†λŠ” ν‚€λ₯Ό κ²€μΆœν•˜μ—¬ κ²½κ³  λ˜λŠ” μ—λŸ¬λ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. + * **Unused Key**: 각 `template.toml`μ—λŠ” μžˆλŠ”λ° μ½”λ“œ μ–΄λ””μ—μ„œλ„ 쓰이지 μ•ŠλŠ” ν‚€λ₯Ό λ¦¬ν¬νŠΈν•˜μ—¬ 정리할 수 있게 ν•©λ‹ˆλ‹€. #### 5.2.3 React (Admin/Dev) κ΅¬ν˜„ κ°€μ΄λ“œ * **νŒ¨ν‚€μ§€ μ„€μΉ˜**: diff --git a/locales/en.toml b/locales/en.toml index 27d15078..648e1126 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -553,6 +553,7 @@ openid = "Openid" profile = "Profile" [msg.dev.dashboard] +description = "Review RP composition and authentication operations in one place." [msg.dev.dashboard.hero] body = "Monitor RP readiness, consent activity, and operational status for the current developer workspace." @@ -560,6 +561,21 @@ title_emphasis = "Title Emphasis" title_prefix = "Title Prefix" title_suffix = "Title Suffix" +[msg.dev.dashboard.chart] +empty = "No RP usage aggregates are available." +filter_description = "View the graph for all apps or only the selected apps." +forbidden = "This account does not have permission to view RP usage statistics." +server_error = "A server error occurred while loading RP usage statistics." +service_unavailable = "The RP usage aggregation service is not ready yet." +unavailable_with_reason = "RP usage statistics are unavailable. {{reason}}" + +[msg.dev.dashboard.distribution] +description = "Quickly review application types and headless login usage." + +[msg.dev.dashboard.recent] +empty = "Review the RPs this account can access." +none = "No linked applications are available." + [msg.dev.dashboard.notice] consent_audit = "Consent Audit" dev_scope = "Dev Scope" @@ -1141,6 +1157,11 @@ parent_organizations = "Parent Organizations" parent_unresolved = "Parent needs review" slug_exists = "slug conflict" title = "Confirm CSV import" +csv_parents = "CSV import" +parent = "Parent" +parent_companies = "Companies" +parent_company_groups = "Company groups" +parent_organizations = "Organizations" [ui.admin.tenants.admins] add_button = "Add Button" @@ -1316,6 +1337,8 @@ parent = "Parent Tenant (Optional)" parent_help = "Select a parent tenant if this is a subsidiary or sub-organization." [ui.admin.tenants.parent] +company_only = "Companies and groups only" +search_placeholder = "Search by name or slug" local_search_placeholder = "Search tenant name or slug" pick_tenant = "Pick tenant" @@ -2177,12 +2200,32 @@ private = "Server side App" [ui.dev.dashboard] ready_badge = "devfront ready" +title = "Dashboard" [ui.dev.dashboard.badge] consent_guard = "Consent guard ready" +oidc = "OIDC operations" policy_toggle = "Policy toggle enabled" +registry = "RP registry" rp_synced = "RP registry synced" +[ui.dev.dashboard.distribution] +headless = "Headless Login" +pkce = "PKCE" +private = "Server side App" +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" + [ui.dev.dashboard.next] subtitle = "Ship the RP controls" title = "Next actions" @@ -2200,11 +2243,25 @@ rp_requests = "Rp Requests" consent = "Consent grants" rp_status = "RP status" +[ui.dev.dashboard.quick_links] +create_button = "Create RP" +new_client = "New RP" +title = "Quick links" + +[ui.dev.dashboard.recent] +title = "My Applications" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" title = "Stack readiness" +[ui.dev.dashboard.summary] +active_clients = "Active RPs" +active_sessions = "Active sessions" +auth_failures_24h = "24h auth failures" +total_clients = "Total RPs" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/locales/ko.toml b/locales/ko.toml index 332c5ac6..f4396f76 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -384,6 +384,11 @@ parent_organizations = "μƒμœ„ 쑰직" parent_unresolved = "λΆ€λͺ¨ 확인 ν•„μš”" slug_exists = "slug 좩돌" title = "CSV κ°€μ Έμ˜€κΈ° 확인" +csv_parents = "κ°€μ Έμ˜€κΈ° CSV" +parent = "μƒμœ„" +parent_companies = "νšŒμ‚¬" +parent_company_groups = "그룹사" +parent_organizations = "쑰직" [ui.common.badge] admin_only = "Admin only" @@ -1039,6 +1044,7 @@ openid = "OIDC 인증 ν•„μˆ˜ μŠ€μ½”ν”„" profile = "κΈ°λ³Έ ν”„λ‘œν•„ 정보 μ ‘κ·Ό" [msg.dev.dashboard] +description = "연동 μ•± ꡬ성과 인증 운영 μ§€ν‘œλ₯Ό ν•œ κ³³μ—μ„œ ν™•μΈν•©λ‹ˆλ‹€." [msg.dev.dashboard.hero] body = "Hydra Admin API와 λ™κΈ°ν™”λœ RP λͺ©λ‘, μƒνƒœ ν† κΈ€, Consent νšŒμˆ˜κΉŒμ§€ devfrontμ—μ„œ μ²˜λ¦¬ν•˜λ„λ‘ μ€€λΉ„ν•©λ‹ˆλ‹€." @@ -1046,6 +1052,21 @@ title_emphasis = " ν•˜λ‚˜μ˜ ν™”λ©΄" title_prefix = "RP 등둝 ν˜„ν™©κ³Ό Consent μƒνƒœλ₯Ό" title_suffix = "μ—μ„œ κ΄€λ¦¬ν•©λ‹ˆλ‹€." +[msg.dev.dashboard.chart] +empty = "ν‘œμ‹œν•  RP 이용 집계가 μ—†μŠ΅λ‹ˆλ‹€." +filter_description = "전체 λ˜λŠ” μ„ νƒν•œ μ• ν”Œλ¦¬μΌ€μ΄μ…˜λ§Œ κΈ°μ€€μœΌλ‘œ κ·Έλž˜ν”„λ₯Ό ν™•μΈν•©λ‹ˆλ‹€." +forbidden = "ν˜„μž¬ κ³„μ •μ—λŠ” RP 이용 톡계λ₯Ό λ³Ό κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€." +server_error = "RP 이용 톡계 쑰회 쀑 μ„œλ²„ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€." +service_unavailable = "RP 이용 톡계 집계 μ„œλΉ„μŠ€κ°€ 아직 μ€€λΉ„λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€." +unavailable_with_reason = "RP 이용 톡계 API 응닡을 확인할 수 μ—†μŠ΅λ‹ˆλ‹€. {{reason}}" + +[msg.dev.dashboard.distribution] +description = "μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μœ ν˜•κ³Ό headless login μ‚¬μš© ν˜„ν™©μ„ λΉ λ₯΄κ²Œ ν™•μΈν•©λ‹ˆλ‹€." + +[msg.dev.dashboard.recent] +empty = "ν˜„μž¬ 계정이 μ ‘κ·Όν•  수 μžˆλŠ” RPλ₯Ό ν™•μΈν•©λ‹ˆλ‹€." +none = "ν‘œμ‹œν•  연동 앱이 μ—†μŠ΅λ‹ˆλ‹€." + [msg.dev.dashboard.notice] consent_audit = "Consent νšŒμˆ˜λŠ” 감사 λ‘œκ·Έμ™€ 연계" dev_scope = "RP 정책은 dev scopeμ—μ„œλ§Œ 적용" @@ -1778,6 +1799,8 @@ parent = "μƒμœ„ ν…Œλ„ŒνŠΈ (선택)" parent_help = "가쑱사 ν…Œλ„ŒνŠΈλ‚˜ ν•˜μœ„ 쑰직을 μ’…μ†μ‹œν‚¬ 경우 μƒμœ„ ν…Œλ„ŒνŠΈλ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”." [ui.admin.tenants.parent] +company_only = "νšŒμ‚¬/κ·Έλ£Ήμ‚¬λ§Œ ν‘œμ‹œ" +search_placeholder = "이름 λ˜λŠ” slug 검색" local_search_placeholder = "ν…Œλ„ŒνŠΈ 이름 λ˜λŠ” 슬러그 검색" pick_tenant = "ν…Œλ„ŒνŠΈ 선택" @@ -2639,12 +2662,32 @@ private = "Server side App" [ui.dev.dashboard] ready_badge = "devfront ready" +title = "λŒ€μ‹œλ³΄λ“œ" [ui.dev.dashboard.badge] consent_guard = "Consent guard ready" +oidc = "OIDC 운영" policy_toggle = "Policy toggle enabled" +registry = "RP registry" rp_synced = "RP registry synced" +[ui.dev.dashboard.distribution] +headless = "Headless Login" +pkce = "PKCE" +private = "Server side App" +title = "μ• ν”Œλ¦¬μΌ€μ΄μ…˜ ꡬ성 μš”μ•½" + +[ui.dev.dashboard.chart] +aria = "RP μš”μ²­ ν˜„ν™©" +filter_all = "전체" +login_requests = "둜그인 μš”μ²­" +other_requests = "기타 μš”μ²­" +period_day = "일" +period_month = "μ›”" +period_week = "μ£Ό" +series = "둜그인 {{login}} / 기타 {{other}} / μ‚¬μš©μž {{subjects}}" +title = "μ• ν”Œλ¦¬μΌ€μ΄μ…˜λ³„ λ‘œκ·ΈμΈμš”μ²­/기타 μš”μ²­ ν˜„ν™©" + [ui.dev.dashboard.next] subtitle = "Ship the RP controls" title = "Next actions" @@ -2662,11 +2705,25 @@ rp_requests = "RP μš”μ²­ 좔이" consent = "Consent grants" rp_status = "RP status" +[ui.dev.dashboard.quick_links] +create_button = "μƒˆ RP λ§Œλ“€κΈ°" +new_client = "μƒˆ RP 생성" +title = "λΉ λ₯Έ 이동" + +[ui.dev.dashboard.recent] +title = "λ‚΄ μ• ν”Œλ¦¬μΌ€μ΄μ…˜" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" title = "Stack readiness" +[ui.dev.dashboard.summary] +active_clients = "ν™œμ„± RP 수" +active_sessions = "ν™œμ„± μ„Έμ…˜ 수" +auth_failures_24h = "24μ‹œκ°„ 인증 μ‹€νŒ¨ 수" +total_clients = "총 RP 수" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/locales/template.toml b/locales/template.toml index 756aa52f..f82577aa 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -248,6 +248,11 @@ parent_organizations = "" parent_unresolved = "" slug_exists = "" title = "" +csv_parents = "" +parent = "" +parent_companies = "" +parent_company_groups = "" +parent_organizations = "" [ui.common.badge] admin_only = "" @@ -903,6 +908,7 @@ openid = "" profile = "" [msg.dev.dashboard] +description = "" [msg.dev.dashboard.hero] body = "" @@ -910,6 +916,21 @@ title_emphasis = "" title_prefix = "" title_suffix = "" +[msg.dev.dashboard.chart] +empty = "" +filter_description = "" +forbidden = "" +server_error = "" +service_unavailable = "" +unavailable_with_reason = "" + +[msg.dev.dashboard.distribution] +description = "" + +[msg.dev.dashboard.recent] +empty = "" +none = "" + [msg.dev.dashboard.notice] consent_audit = "" dev_scope = "" @@ -1643,6 +1664,8 @@ parent = "" parent_help = "" [ui.admin.tenants.parent] +company_only = "" +search_placeholder = "" local_search_placeholder = "" pick_tenant = "" @@ -2518,12 +2541,32 @@ private = "" [ui.dev.dashboard] ready_badge = "" +title = "" [ui.dev.dashboard.badge] consent_guard = "" +oidc = "" policy_toggle = "" +registry = "" rp_synced = "" +[ui.dev.dashboard.distribution] +headless = "" +pkce = "" +private = "" +title = "" + +[ui.dev.dashboard.chart] +aria = "" +filter_all = "" +login_requests = "" +other_requests = "" +period_day = "" +period_month = "" +period_week = "" +series = "" +title = "" + [ui.dev.dashboard.next] subtitle = "" title = "" @@ -2541,11 +2584,25 @@ rp_requests = "" consent = "" rp_status = "" +[ui.dev.dashboard.quick_links] +create_button = "" +new_client = "" +title = "" + +[ui.dev.dashboard.recent] +title = "" + [ui.dev.dashboard.stack] notes = "" subtitle = "" title = "" +[ui.dev.dashboard.summary] +active_clients = "" +active_sessions = "" +auth_failures_24h = "" +total_clients = "" + [ui.dev.header] plane = "" subtitle = "" diff --git a/orgfront/src/app/queryClient.ts b/orgfront/src/app/queryClient.ts index 06c9afde..9064efe8 100644 --- a/orgfront/src/app/queryClient.ts +++ b/orgfront/src/app/queryClient.ts @@ -1,11 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; +import { queryClientDefaultOptions } from "../../../common/core/query/queryClient"; + export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - refetchOnWindowFocus: false, - retry: 1, - }, - }, + defaultOptions: queryClientDefaultOptions, }); diff --git a/orgfront/src/components/layout/AppLayout.tsx b/orgfront/src/components/layout/AppLayout.tsx index 4e0eb33b..157b841b 100644 --- a/orgfront/src/components/layout/AppLayout.tsx +++ b/orgfront/src/components/layout/AppLayout.tsx @@ -19,6 +19,15 @@ import { shouldAttemptSlidingSessionRenew, shouldAttemptUnlimitedSessionRenew, } from "../../lib/sessionSliding"; +import { + applyShellTheme, + buildShellProfileSummary, + buildShellSessionStatus, + readShellSessionExpiryEnabled, + readShellTheme, + shellLayoutClasses, + writeShellSessionExpiryEnabled, +} from "../../../../common/shell"; import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; @@ -45,15 +54,11 @@ function AppLayout() { const isRenewInFlightRef = useRef(false); const lastRenewAttemptAtRef = useRef(0); const lastVisitedRouteRef = useRef(null); - const [theme, setTheme] = useState<"light" | "dark">(() => { - const stored = window.localStorage.getItem("admin_theme"); - return stored === "dark" ? "dark" : "light"; - }); + const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); - const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => { - const stored = window.localStorage.getItem("baron_session_expiry_enabled"); - return stored !== "false"; - }); + const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState( + readShellSessionExpiryEnabled, + ); const [nowMs, setNowMs] = useState(() => Date.now()); const hasAccessToken = Boolean(auth.user?.access_token); @@ -71,14 +76,7 @@ function AppLayout() { }; useEffect(() => { - const root = document.documentElement; - root.classList.remove("light", "dark"); - if (theme === "light") { - root.classList.add("light"); - } else { - root.classList.add("dark"); - } - window.localStorage.setItem("admin_theme", theme); + applyShellTheme(theme); }, [theme]); useEffect(() => { @@ -254,17 +252,16 @@ function AppLayout() { setTheme((prev) => (prev === "light" ? "dark" : "light")); }; - const profileName = - profile?.name?.trim() || - auth.user?.profile?.name?.toString().trim() || - auth.user?.profile?.preferred_username?.toString().trim() || - auth.user?.profile?.nickname?.toString().trim() || - t("ui.dev.profile.unknown_name", "Unknown User"); - const profileEmail = - profile?.email?.trim() || - auth.user?.profile?.email?.toString().trim() || - t("ui.dev.profile.unknown_email", "unknown@example.com"); - const profileInitial = profileName.charAt(0).toUpperCase(); + const profileSummary = buildShellProfileSummary({ + profileName: + profile?.name || + auth.user?.profile?.name?.toString() || + auth.user?.profile?.preferred_username?.toString() || + auth.user?.profile?.nickname?.toString(), + profileEmail: profile?.email || auth.user?.profile?.email?.toString(), + fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"), + fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"), + }); const currentRole = resolveProfileRole( auth.user?.profile as Record | undefined, ); @@ -275,68 +272,27 @@ function AppLayout() { "tenant_admin", "rp_admin", ].includes(currentRole); - const expiresAtSec = auth.user?.expires_at; - const remainingMs = - typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null; - const remainingTotalSec = - remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null; - const remainingMinutes = - remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null; - const remainingSeconds = - remainingTotalSec !== null ? remainingTotalSec % 60 : null; - - let sessionToneClass = - "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - let sessionText = t("ui.dev.session.active", "μ„Έμ…˜ ν™œμ„±"); - - if (remainingMs === null) { - sessionToneClass = "border-border bg-card text-muted-foreground"; - sessionText = t("ui.dev.session.unknown", "μ•Œ 수 μ—†μŒ"); - } else if (remainingMs <= 0) { - sessionToneClass = - "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; - sessionText = t("ui.dev.session.expired", "μ„Έμ…˜ 만료"); - } else if ( - remainingMinutes !== null && - remainingSeconds !== null && - remainingMinutes <= 5 - ) { - sessionToneClass = - "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; - sessionText = t( - "ui.dev.session.expiring", - "만료 μž„λ°•: {{minutes}}λΆ„ {{seconds}}초 λ‚¨μŒ", - { - minutes: remainingMinutes, - seconds: remainingSeconds, - }, - ); - } else { - sessionText = t( - "ui.dev.session.remaining", - "만료 μ˜ˆμ •: {{minutes}}λΆ„ {{seconds}}초 λ‚¨μŒ", - { - minutes: remainingMinutes ?? 0, - seconds: remainingSeconds ?? 0, - }, - ); - } + const sessionStatus = buildShellSessionStatus({ + expiresAtSec: auth.user?.expires_at, + nowMs, + t, + }); const handleSessionExpiryToggle = () => { setIsSessionExpiryEnabled((prev) => { const next = !prev; - window.localStorage.setItem("baron_session_expiry_enabled", String(next)); + writeShellSessionExpiryEnabled(next); return next; }); }; return ( -
-