forked from baron/baron-sso
Merge pull request 'feature/common-core' (#768) from feature/common-core into dev
Reviewed-on: baron/baron-sso#768
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<aside className={shellLayoutClasses.asideStatic}>
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -480,8 +436,8 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<nav className={shellLayoutClasses.navWrap}>
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map((item: NavItem) => {
|
||||
const { label, to, icon: Icon, isExternal } = item;
|
||||
const isOrgChart = location.pathname === "/tenants/org-chart";
|
||||
@@ -499,7 +455,10 @@ function AppLayout() {
|
||||
href={to}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
|
||||
className={[
|
||||
shellLayoutClasses.navItemBase,
|
||||
shellLayoutClasses.navItemIdle,
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(label, label)}</span>
|
||||
@@ -513,10 +472,10 @@ function AppLayout() {
|
||||
to={to}
|
||||
className={() =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
shellLayoutClasses.navItemBase,
|
||||
isCustomActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
@@ -531,7 +490,7 @@ function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
@@ -540,10 +499,10 @@ function AppLayout() {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="relative min-w-0">
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={shellLayoutClasses.contentWide}>
|
||||
<header className={shellLayoutClasses.headerElevated}>
|
||||
<div className={shellLayoutClasses.headerInner}>
|
||||
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
||||
</p>
|
||||
@@ -552,12 +511,12 @@ function AppLayout() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={shellLayoutClasses.headerActions}>
|
||||
<LanguageSelector />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
className={shellLayoutClasses.actionButton}
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -568,11 +527,11 @@ function AppLayout() {
|
||||
{isSessionExpiryEnabled ? (
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
shellLayoutClasses.sessionBadge,
|
||||
sessionStatus.toneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
{sessionStatus.text}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
@@ -584,15 +543,15 @@ function AppLayout() {
|
||||
aria-expanded={isProfileOpen}
|
||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{profileInitial}
|
||||
<div className={shellLayoutClasses.profileInitial}>
|
||||
{profileSummary.initial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -602,20 +561,17 @@ function AppLayout() {
|
||||
</button>
|
||||
|
||||
{isProfileOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||
>
|
||||
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.profileCard}>
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
@@ -628,7 +584,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.settingsCard}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
@@ -636,7 +592,7 @@ function AppLayout() {
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
? sessionStatus.text
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
@@ -736,7 +692,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
|
||||
@@ -1,38 +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",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CommonBadgeVariant;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<div
|
||||
className={cn(getCommonBadgeClasses({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge };
|
||||
|
||||
@@ -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<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -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<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -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<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
className={cn(commonInputClass, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
sortItems,
|
||||
toggleSort,
|
||||
type SortConfig,
|
||||
type SortResolverMap,
|
||||
} from "../../../../../common/core/utils";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
@@ -82,10 +88,7 @@ import {
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
||||
|
||||
type SortConfig = {
|
||||
key: keyof TenantSummary | "recursiveMemberCount";
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
|
||||
|
||||
const getTenantIcon = (type?: string) => {
|
||||
switch (type?.toUpperCase()) {
|
||||
@@ -225,7 +228,8 @@ function TenantListPage() {
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<TenantSortKey> | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [importMessage, setImportMessage] = React.useState("");
|
||||
const [previewRows, setPreviewRows] = React.useState<
|
||||
@@ -363,6 +367,17 @@ function TenantListPage() {
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const importParentOptionGroups =
|
||||
buildTenantImportParentOptionGroups(allTenants);
|
||||
const tenantSortResolvers = React.useMemo<
|
||||
SortResolverMap<
|
||||
TenantSummary & { recursiveMemberCount: number },
|
||||
TenantSortKey
|
||||
>
|
||||
>(
|
||||
() => ({
|
||||
recursiveMemberCount: (tenant) => tenant.recursiveMemberCount,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const tenants = React.useMemo(() => {
|
||||
// 1. Calculate recursive counts
|
||||
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
||||
@@ -396,38 +411,14 @@ function TenantListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (sortConfig) {
|
||||
enriched.sort((a, b) => {
|
||||
const aValue = a[sortConfig.key as keyof typeof a];
|
||||
const bValue = b[sortConfig.key as keyof typeof b];
|
||||
return sortItems(enriched, sortConfig, tenantSortResolvers);
|
||||
}, [allTenants, search, sortConfig, tenantSortResolvers]);
|
||||
|
||||
if (aValue === bValue) return 0;
|
||||
if (aValue === null || aValue === undefined) return 1;
|
||||
if (bValue === null || bValue === undefined) return -1;
|
||||
|
||||
if (sortConfig.direction === "asc") {
|
||||
return aValue < bValue ? -1 : 1;
|
||||
}
|
||||
return aValue > bValue ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}, [allTenants, search, sortConfig]);
|
||||
|
||||
const requestSort = (key: SortConfig["key"]) => {
|
||||
let direction: "asc" | "desc" = "asc";
|
||||
if (
|
||||
sortConfig &&
|
||||
sortConfig.key === key &&
|
||||
sortConfig.direction === "asc"
|
||||
) {
|
||||
direction = "desc";
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
const requestSort = (key: TenantSortKey) => {
|
||||
setSortConfig((current) => toggleSort(current, key));
|
||||
};
|
||||
|
||||
const getSortIcon = (key: SortConfig["key"]) => {
|
||||
const getSortIcon = (key: TenantSortKey) => {
|
||||
if (!sortConfig || sortConfig.key !== key) {
|
||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ import {
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
sortItems,
|
||||
toggleSort,
|
||||
type SortConfig,
|
||||
type SortResolverMap,
|
||||
} from "../../../../common/core/utils";
|
||||
import {
|
||||
type UserSummary,
|
||||
bulkDeleteUsers,
|
||||
@@ -71,10 +77,7 @@ type UserSchemaField = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
type SortConfig = {
|
||||
key: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
type UserSortKey = string;
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -86,7 +89,8 @@ function UserListPage() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<UserSortKey> | null>(null);
|
||||
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -219,60 +223,40 @@ function UserListPage() {
|
||||
: null;
|
||||
|
||||
const rawItems = query.data?.items ?? [];
|
||||
const userSortResolvers = React.useMemo<
|
||||
SortResolverMap<UserSummary, UserSortKey>
|
||||
>(
|
||||
() =>
|
||||
userSchema.reduce<SortResolverMap<UserSummary, UserSortKey>>(
|
||||
(accumulator, field) => {
|
||||
accumulator[field.key] = (user) => {
|
||||
const value = user.metadata?.[field.key];
|
||||
return typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
: null;
|
||||
};
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
name_email: (user) =>
|
||||
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
|
||||
tenant_dept: (user) =>
|
||||
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`,
|
||||
},
|
||||
),
|
||||
[userSchema],
|
||||
);
|
||||
const items = React.useMemo(() => {
|
||||
const sorted = [...rawItems];
|
||||
if (sortConfig) {
|
||||
sorted.sort((a, b) => {
|
||||
let aValue: string | number | boolean | null | undefined;
|
||||
let bValue: string | number | boolean | null | undefined;
|
||||
return sortItems(rawItems, sortConfig, userSortResolvers);
|
||||
}, [rawItems, sortConfig, userSortResolvers]);
|
||||
|
||||
if (sortConfig.key === "name_email") {
|
||||
aValue = a.name?.toLowerCase() || "";
|
||||
bValue = b.name?.toLowerCase() || "";
|
||||
} else if (sortConfig.key === "tenant_dept") {
|
||||
aValue =
|
||||
(a.tenant?.name || a.tenantSlug || "").toLowerCase() +
|
||||
(a.department || "").toLowerCase();
|
||||
bValue =
|
||||
(b.tenant?.name || b.tenantSlug || "").toLowerCase() +
|
||||
(b.department || "").toLowerCase();
|
||||
} else {
|
||||
aValue = (a as Record<string, unknown>)[sortConfig.key] as
|
||||
| string
|
||||
| number
|
||||
| boolean;
|
||||
bValue = (b as Record<string, unknown>)[sortConfig.key] as
|
||||
| string
|
||||
| number
|
||||
| boolean;
|
||||
}
|
||||
|
||||
if (aValue === bValue) return 0;
|
||||
if (aValue === null || aValue === undefined) return 1;
|
||||
if (bValue === null || bValue === undefined) return -1;
|
||||
|
||||
if (sortConfig.direction === "asc") {
|
||||
return aValue < bValue ? -1 : 1;
|
||||
}
|
||||
return aValue > bValue ? -1 : 1;
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}, [rawItems, sortConfig]);
|
||||
|
||||
const requestSort = (key: SortConfig["key"]) => {
|
||||
let direction: "asc" | "desc" = "asc";
|
||||
if (
|
||||
sortConfig &&
|
||||
sortConfig.key === key &&
|
||||
sortConfig.direction === "asc"
|
||||
) {
|
||||
direction = "desc";
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
const requestSort = (key: UserSortKey) => {
|
||||
setSortConfig((current) => toggleSort(current, key));
|
||||
};
|
||||
|
||||
const getSortIcon = (key: SortConfig["key"]) => {
|
||||
const getSortIcon = (key: UserSortKey) => {
|
||||
if (!sortConfig || sortConfig.key !== key) {
|
||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||
import type { AuthProviderProps } from "react-oidc-context";
|
||||
import {
|
||||
buildAdminAuthRedirectUris,
|
||||
resolveAdminPublicOrigin,
|
||||
} from "./authConfig";
|
||||
buildCommonOidcRuntimeConfig,
|
||||
buildCommonUserManagerSettings,
|
||||
} from "../../../common/core/auth";
|
||||
import { resolveAdminPublicOrigin } from "./authConfig";
|
||||
|
||||
const adminPublicOrigin = resolveAdminPublicOrigin(
|
||||
import.meta.env.VITE_ADMIN_PUBLIC_URL,
|
||||
window.location.origin,
|
||||
);
|
||||
const adminRedirectUris = buildAdminAuthRedirectUris(adminPublicOrigin);
|
||||
|
||||
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 || "adminfront",
|
||||
redirect_uri: adminRedirectUris.redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||
post_logout_redirect_uri: adminRedirectUris.postLogoutRedirectUri,
|
||||
popup_redirect_uri: adminRedirectUris.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 || "adminfront",
|
||||
origin: adminPublicOrigin,
|
||||
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),
|
||||
);
|
||||
|
||||
@@ -1,148 +1,21 @@
|
||||
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<Locale, TomlObject> = {
|
||||
ko: parseToml(koRaw),
|
||||
en: parseToml(enRaw),
|
||||
};
|
||||
|
||||
function formatTemplate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
if (!vars) {
|
||||
return template;
|
||||
}
|
||||
return template.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, string | number>,
|
||||
): 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],
|
||||
},
|
||||
{
|
||||
normalizeEscapedNewlines: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,106 +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") {
|
||||
console.debug(
|
||||
"[sessionSliding] expiresAtSec is not a number, skipping renew",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const remainingMs = expiresAtSec * 1000 - nowMs;
|
||||
const remainingMin = Math.floor(remainingMs / 1000 / 60);
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
console.debug("[sessionSliding] Session already expired, skipping renew");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (remainingMs > thresholdMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nowMs - lastAttemptAtMs < throttleMs) {
|
||||
console.debug("[sessionSliding] Throttling renewal attempt");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`,
|
||||
);
|
||||
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") {
|
||||
console.debug(
|
||||
"[sessionSliding] expiresAtSec is not a number, skipping unlimited renew",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const remainingMs = expiresAtSec * 1000 - nowMs;
|
||||
const remainingMin = Math.floor(remainingMs / 1000 / 60);
|
||||
|
||||
if (remainingMs <= 0) {
|
||||
console.debug(
|
||||
"[sessionSliding] Session already expired, skipping unlimited renew",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (remainingMs > thresholdMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nowMs - lastAttemptAtMs < throttleMs) {
|
||||
console.debug("[sessionSliding] Throttling unlimited renewal attempt");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`,
|
||||
);
|
||||
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";
|
||||
|
||||
72
adminfront/src/lib/sort.test.ts
Normal file
72
adminfront/src/lib/sort.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareNullableValues,
|
||||
sortItems,
|
||||
toggleSort,
|
||||
type SortConfig,
|
||||
} from "../../../common/core/utils";
|
||||
|
||||
describe("shared sort helpers", () => {
|
||||
it("toggles sort direction for the same key", () => {
|
||||
expect(toggleSort<string>(null, "name")).toEqual({
|
||||
key: "name",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
expect(
|
||||
toggleSort<string>({ key: "name", direction: "asc" }, "name"),
|
||||
).toEqual({
|
||||
key: "name",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
expect(
|
||||
toggleSort<string>({ key: "name", direction: "desc" }, "status"),
|
||||
).toEqual({
|
||||
key: "status",
|
||||
direction: "asc",
|
||||
});
|
||||
});
|
||||
|
||||
it("compares nullable values with nulls last", () => {
|
||||
expect(compareNullableValues("a", "b", "asc")).toBeLessThan(0);
|
||||
expect(compareNullableValues("a", "b", "desc")).toBeGreaterThan(0);
|
||||
expect(compareNullableValues(null, "b", "asc")).toBeGreaterThan(0);
|
||||
expect(compareNullableValues("b", null, "asc")).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("sorts items with resolver maps", () => {
|
||||
const items = [
|
||||
{
|
||||
id: "2",
|
||||
name: "Beta",
|
||||
metadata: { score: 2 },
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "gamma",
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
name: "alpha",
|
||||
metadata: { score: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const nameSort: SortConfig<"name"> = { key: "name", direction: "asc" };
|
||||
expect(sortItems(items, nameSort).map((item) => item.id)).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
]);
|
||||
|
||||
const scoreSort: SortConfig<"score"> = { key: "score", direction: "asc" };
|
||||
expect(
|
||||
sortItems(items, scoreSort, {
|
||||
score: (item) =>
|
||||
typeof item.metadata.score === "number" ? item.metadata.score : null,
|
||||
}).map((item) => item.id),
|
||||
).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
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)]);
|
||||
}
|
||||
|
||||
export function generateSecurePassword(length = 16): string {
|
||||
|
||||
@@ -327,15 +327,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?"
|
||||
|
||||
@@ -1281,63 +1272,6 @@ name = "Name"
|
||||
role = "Role"
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = "Add"
|
||||
all = "All"
|
||||
admin_only = "Admin Only"
|
||||
assign = "Assign"
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
change_file = "Change File"
|
||||
clear_search = "Clear Search"
|
||||
close = "Close"
|
||||
collapse = "Collapse"
|
||||
confirm = "Confirm"
|
||||
copy = "Copy"
|
||||
create = "Create"
|
||||
delete = "Delete"
|
||||
details = "Details"
|
||||
edit = "Edit"
|
||||
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.role]
|
||||
admin = "Admin"
|
||||
rp_admin = "RP Admin"
|
||||
|
||||
@@ -329,15 +329,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 = "로그아웃 하시겠습니까?"
|
||||
|
||||
@@ -1283,63 +1274,6 @@ name = "이름"
|
||||
role = "역할"
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = "추가"
|
||||
all = "전체"
|
||||
admin_only = "관리자 전용"
|
||||
assign = "할당"
|
||||
back = "돌아가기"
|
||||
cancel = "취소"
|
||||
change_file = "파일 변경"
|
||||
clear_search = "검색 초기화"
|
||||
close = "닫기"
|
||||
collapse = "접기"
|
||||
confirm = "확인"
|
||||
copy = "복사"
|
||||
create = "생성"
|
||||
delete = "삭제"
|
||||
details = "상세정보"
|
||||
edit = "편집"
|
||||
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.role]
|
||||
admin = "Admin"
|
||||
rp_admin = "RP Admin"
|
||||
|
||||
@@ -337,15 +337,6 @@ no_custom = ""
|
||||
[msg.admin.users.list.registry]
|
||||
count = ""
|
||||
|
||||
[msg.common]
|
||||
error = ""
|
||||
loading = ""
|
||||
no_description = ""
|
||||
parsing = ""
|
||||
requesting = ""
|
||||
saving = ""
|
||||
unknown_error = ""
|
||||
|
||||
[msg.dev]
|
||||
logout_confirm = ""
|
||||
|
||||
@@ -1261,63 +1252,6 @@ name = ""
|
||||
role = ""
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = ""
|
||||
all = ""
|
||||
admin_only = ""
|
||||
assign = ""
|
||||
back = ""
|
||||
cancel = ""
|
||||
change_file = ""
|
||||
clear_search = ""
|
||||
close = ""
|
||||
collapse = ""
|
||||
confirm = ""
|
||||
copy = ""
|
||||
create = ""
|
||||
delete = ""
|
||||
details = ""
|
||||
edit = ""
|
||||
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.role]
|
||||
admin = ""
|
||||
rp_admin = ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -376,6 +376,7 @@ func main() {
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||
devHandler.RPUsageQueries = rpUsageQueryRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
@@ -825,6 +826,7 @@ func main() {
|
||||
dev.Get("/consents", devHandler.ListConsents)
|
||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||
dev.Get("/rp-usage/daily", devHandler.GetRPUsageDaily)
|
||||
|
||||
// [New] Developer Registration Flow
|
||||
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
||||
|
||||
@@ -39,6 +39,7 @@ type DevHandler struct {
|
||||
TenantSvc service.TenantService
|
||||
DeveloperSvc *service.DeveloperService
|
||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
Auth interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
}
|
||||
@@ -94,6 +95,13 @@ type devStatsResponse struct {
|
||||
AuthFailures int64 `json:"auth_failures_24h"`
|
||||
}
|
||||
|
||||
type devRPUsageDailyResponse struct {
|
||||
Items []domain.RPUsageDailyMetric `json:"items"`
|
||||
Days int `json:"days"`
|
||||
Period string `json:"period"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
}
|
||||
|
||||
type clientSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -369,16 +377,12 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
|
||||
if role == domain.RoleSuperAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
||||
if canAccessClientByLegacyScope(profile, summary) {
|
||||
return true
|
||||
}
|
||||
|
||||
clientTenantID := resolveClientTenantID(summary)
|
||||
if role != domain.RoleUser && clientTenantID != "" {
|
||||
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
|
||||
return true
|
||||
}
|
||||
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
||||
return true
|
||||
}
|
||||
|
||||
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
|
||||
@@ -512,6 +516,26 @@ func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[strin
|
||||
return dst
|
||||
}
|
||||
|
||||
func shouldScopeDashboardToExplicitClients(role string) bool {
|
||||
switch normalizeUserRole(role) {
|
||||
case domain.RoleRPAdmin, domain.RoleUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clientIDSetFromSummaries(items []clientSummary) map[string]struct{} {
|
||||
ids := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id != "" {
|
||||
ids[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||
if profile == nil {
|
||||
return false
|
||||
@@ -1009,6 +1033,70 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (h *DevHandler) listVisibleClientSummaries(
|
||||
c *fiber.Ctx,
|
||||
profile *domain.UserProfileResponse,
|
||||
limit int,
|
||||
offset int,
|
||||
) ([]clientSummary, error) {
|
||||
if profile == nil {
|
||||
return nil, fiber.NewError(fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||
}
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if !isDevConsoleRoleAllowed(role) {
|
||||
return nil, fiber.NewError(fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
userTenantID := tenantIDFromProfile(profile)
|
||||
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||
allowedClientIDs := managedClientIDsFromProfile(profile)
|
||||
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to check app manager permission", "error", err)
|
||||
}
|
||||
|
||||
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]clientSummary, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
if isHiddenSystemClient(client) {
|
||||
continue
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(client)
|
||||
canViewByPermit := h.canViewClientByPermit(c, profile, summary)
|
||||
|
||||
if summary.Type == "private" && !isAppManager && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isSuperAdmin {
|
||||
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
||||
if clientTenantID != userTenantID && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
|
||||
if _, ok := allowedClientIDs[summary.ID]; !ok && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary))
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
|
||||
authHeader = strings.TrimSpace(authHeader)
|
||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
@@ -1133,72 +1221,22 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||
}
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if !isDevConsoleRoleAllowed(role) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
userTenantID := tenantIDFromProfile(profile)
|
||||
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||
allowedClientIDs := managedClientIDsFromProfile(profile)
|
||||
|
||||
isAppManager, err := h.checkAppManagerPermission(c)
|
||||
items, err := h.listVisibleClientSummaries(c, profile, limit, offset)
|
||||
if err != nil {
|
||||
slog.Error("Failed to check app manager permission", "error", err)
|
||||
}
|
||||
|
||||
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return errorJSON(c, fiber.StatusNotFound, "clients not found")
|
||||
}
|
||||
status := fiber.StatusInternalServerError
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
|
||||
var fiberErr *fiber.Error
|
||||
if errors.As(err, &fiberErr) {
|
||||
status = fiberErr.Code
|
||||
errMsg = fiberErr.Message
|
||||
} else if errors.Is(err, service.ErrHydraNotFound) {
|
||||
status = fiber.StatusNotFound
|
||||
errMsg = "clients not found"
|
||||
} else if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
||||
status = fiber.StatusServiceUnavailable
|
||||
errMsg = "Hydra service is unavailable. Please check if Ory Hydra is running."
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, errMsg)
|
||||
}
|
||||
|
||||
items := make([]clientSummary, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
if isHiddenSystemClient(client) {
|
||||
continue
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(client)
|
||||
|
||||
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
||||
canViewByPermit := h.canViewClientByPermit(c, profile, summary)
|
||||
|
||||
if summary.Type == "private" && !isAppManager && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant
|
||||
if !isSuperAdmin {
|
||||
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
||||
if clientTenantID != userTenantID && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists
|
||||
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
|
||||
if _, ok := allowedClientIDs[summary.ID]; !ok {
|
||||
if !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary))
|
||||
return errorJSON(c, status, errMsg)
|
||||
}
|
||||
|
||||
return c.JSON(clientListResponse{
|
||||
@@ -2547,59 +2585,42 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
||||
|
||||
func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
||||
h.injectTenantContextFromHeader(c)
|
||||
|
||||
// [Security] Check permission
|
||||
allowed, err := h.checkAppManagerPermission(c)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||
}
|
||||
if !allowed {
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if !isDevConsoleViewerRole(role) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
|
||||
userTenantID := ""
|
||||
isSuperAdmin := false
|
||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
||||
isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin
|
||||
if profile.TenantID != nil {
|
||||
userTenantID = *profile.TenantID
|
||||
}
|
||||
visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients")
|
||||
}
|
||||
|
||||
// 1. Total Clients (Tenant Scoped)
|
||||
// Hydra doesn't support tenant filtering natively, so we list and filter.
|
||||
// For stats, we might want to fetch a larger batch or use a cached count.
|
||||
clients, err := h.Hydra.ListClients(c.Context(), 500, 0)
|
||||
var totalClients int64
|
||||
if err == nil {
|
||||
for _, client := range clients {
|
||||
if isHiddenSystemClient(client) {
|
||||
continue
|
||||
}
|
||||
if isSuperAdmin {
|
||||
totalClients++
|
||||
continue
|
||||
}
|
||||
if client.Metadata != nil {
|
||||
if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID {
|
||||
totalClients++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
userTenantID := tenantIDFromProfile(profile)
|
||||
totalClients := int64(len(visibleClients))
|
||||
visibleClientIDs := clientIDSetFromSummaries(visibleClients)
|
||||
|
||||
// 2. Auth Failures (24h)
|
||||
var authFailures int64
|
||||
if h.AuditRepo != nil {
|
||||
since := time.Now().Add(-24 * time.Hour)
|
||||
authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), since, userTenantID)
|
||||
}
|
||||
|
||||
// 3. Active Sessions (1h)
|
||||
var activeSessions int64
|
||||
if h.AuditRepo != nil {
|
||||
since := time.Now().Add(-1 * time.Hour)
|
||||
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID)
|
||||
failureSince := time.Now().Add(-24 * time.Hour)
|
||||
sessionSince := time.Now().Add(-1 * time.Hour)
|
||||
if shouldScopeDashboardToExplicitClients(role) {
|
||||
authFailures, activeSessions, _ = h.countScopedDashboardAuditMetrics(
|
||||
c,
|
||||
userTenantID,
|
||||
visibleClientIDs,
|
||||
failureSince,
|
||||
sessionSince,
|
||||
)
|
||||
} else {
|
||||
authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), failureSince, userTenantID)
|
||||
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), sessionSince, userTenantID)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(devStatsResponse{
|
||||
@@ -2609,6 +2630,75 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DevHandler) GetRPUsageDaily(c *fiber.Ctx) error {
|
||||
h.injectTenantContextFromHeader(c)
|
||||
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||
}
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if !isDevConsoleViewerRole(role) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
if h == nil || h.RPUsageQueries == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "rp usage query service unavailable")
|
||||
}
|
||||
|
||||
days := 14
|
||||
if raw := c.Query("days"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
period := normalizeRPUsagePeriod(c.Query("period"))
|
||||
|
||||
visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients")
|
||||
}
|
||||
|
||||
allowedClientIDs := clientIDSetFromSummaries(visibleClients)
|
||||
if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 {
|
||||
return c.JSON(devRPUsageDailyResponse{
|
||||
Items: []domain.RPUsageDailyMetric{},
|
||||
Days: days,
|
||||
Period: period,
|
||||
})
|
||||
}
|
||||
|
||||
tenantID := ""
|
||||
if role != domain.RoleSuperAdmin {
|
||||
tenantID = tenantIDFromProfile(profile)
|
||||
}
|
||||
|
||||
items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{
|
||||
Days: days,
|
||||
Period: period,
|
||||
TenantID: tenantID,
|
||||
})
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
filtered := make([]domain.RPUsageDailyMetric, 0, len(items))
|
||||
for _, item := range items {
|
||||
if role != domain.RoleSuperAdmin {
|
||||
if _, ok := allowedClientIDs[strings.TrimSpace(item.ClientID)]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
|
||||
return c.JSON(devRPUsageDailyResponse{
|
||||
Items: filtered,
|
||||
Days: days,
|
||||
Period: period,
|
||||
TenantID: tenantID,
|
||||
})
|
||||
}
|
||||
|
||||
func generateRandomSecret(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
@@ -3262,6 +3352,74 @@ func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *DevHandler) countScopedDashboardAuditMetrics(
|
||||
c *fiber.Ctx,
|
||||
tenantID string,
|
||||
allowedClientIDs map[string]struct{},
|
||||
failureSince, sessionSince time.Time,
|
||||
) (int64, int64, error) {
|
||||
if h.AuditRepo == nil || len(allowedClientIDs) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
oldestSince := failureSince
|
||||
if sessionSince.Before(oldestSince) {
|
||||
oldestSince = sessionSince
|
||||
}
|
||||
|
||||
var failureCount int64
|
||||
activeSessions := make(map[string]struct{})
|
||||
var cursor *domain.AuditCursor
|
||||
|
||||
const pageSize = 200
|
||||
const maxScan = 5000
|
||||
scanned := 0
|
||||
|
||||
for scanned < maxScan {
|
||||
page, err := h.AuditRepo.FindPage(c.Context(), pageSize, cursor, tenantID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(page) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
stop := false
|
||||
for _, logItem := range page {
|
||||
scanned++
|
||||
if logItem.Timestamp.Before(oldestSince) {
|
||||
stop = true
|
||||
break
|
||||
}
|
||||
|
||||
details, _ := utils.ParseAuditDetails(logItem.Details)
|
||||
clientID := strings.TrimSpace(resolveDevAuditClientID(logItem, details))
|
||||
if _, ok := allowedClientIDs[clientID]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(logItem.Status, "failure") && !logItem.Timestamp.Before(failureSince) {
|
||||
failureCount++
|
||||
}
|
||||
if strings.EqualFold(logItem.Status, "success") && !logItem.Timestamp.Before(sessionSince) {
|
||||
sessionID := strings.TrimSpace(logItem.SessionID)
|
||||
if sessionID != "" {
|
||||
activeSessions[sessionID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stop || len(page) < pageSize {
|
||||
break
|
||||
}
|
||||
|
||||
last := page[len(page)-1]
|
||||
cursor = &domain.AuditCursor{Timestamp: last.Timestamp, EventID: last.EventID}
|
||||
}
|
||||
|
||||
return failureCount, int64(len(activeSessions)), nil
|
||||
}
|
||||
|
||||
// ListMyTenants returns the list of tenants the current user manages or belongs to.
|
||||
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||
profile, err := h.Auth.GetEnrichedProfile(c)
|
||||
|
||||
@@ -942,7 +942,6 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
@@ -1368,6 +1367,22 @@ func TestGetStats_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On(
|
||||
"CheckPermission",
|
||||
mock.Anything,
|
||||
"User:u1",
|
||||
"RelyingParty",
|
||||
mock.Anything,
|
||||
"view",
|
||||
).Return(false, nil).Maybe()
|
||||
mockKeto.On(
|
||||
"ListRelations",
|
||||
mock.Anything,
|
||||
"RelyingParty",
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
@@ -1400,6 +1415,164 @@ func TestGetStats_Success(t *testing.T) {
|
||||
mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all")
|
||||
}
|
||||
|
||||
func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
|
||||
now := time.Now()
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||
{"client_id": "client-owned", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
||||
{"client_id": "client-other", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
auditRepo := &mockAuditRepo{
|
||||
logs: []domain.AuditLog{
|
||||
{
|
||||
EventID: "evt-1",
|
||||
Timestamp: now.Add(-15 * time.Minute),
|
||||
SessionID: "sess-owned",
|
||||
Status: "success",
|
||||
EventType: "GET /api/v1/dev/clients/client-owned",
|
||||
Details: `{"client_id":"client-owned","tenant_id":"tenant-a"}`,
|
||||
},
|
||||
{
|
||||
EventID: "evt-2",
|
||||
Timestamp: now.Add(-20 * time.Minute),
|
||||
Status: "failure",
|
||||
EventType: "GET /api/v1/dev/clients/client-owned",
|
||||
Details: `{"client_id":"client-owned","tenant_id":"tenant-a"}`,
|
||||
},
|
||||
{
|
||||
EventID: "evt-3",
|
||||
Timestamp: now.Add(-10 * time.Minute),
|
||||
SessionID: "sess-other",
|
||||
Status: "success",
|
||||
EventType: "GET /api/v1/dev/clients/client-other",
|
||||
Details: `{"client_id":"client-other","tenant_id":"tenant-a"}`,
|
||||
},
|
||||
{
|
||||
EventID: "evt-4",
|
||||
Timestamp: now.Add(-30 * time.Minute),
|
||||
Status: "failure",
|
||||
EventType: "GET /api/v1/dev/clients/client-other",
|
||||
Details: `{"client_id":"client-other","tenant_id":"tenant-a"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil)
|
||||
mockKeto.On(
|
||||
"ListRelations",
|
||||
mock.Anything,
|
||||
"RelyingParty",
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
AuditRepo: auditRepo,
|
||||
Keto: mockKeto,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/stats", h.GetStats)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/stats", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res devStatsResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
assert.Equal(t, int64(1), res.TotalClients)
|
||||
assert.Equal(t, int64(1), res.AuthFailures)
|
||||
assert.Equal(t, int64(1), res.ActiveSessions)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/clients" {
|
||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||
{"client_id": "client-owned", "client_name": "Owned App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
||||
{"client_id": "client-other", "client_name": "Other App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil)
|
||||
mockKeto.On(
|
||||
"ListRelations",
|
||||
mock.Anything,
|
||||
"RelyingParty",
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
mock.Anything,
|
||||
).Return([]service.RelationTuple{}, nil).Maybe()
|
||||
|
||||
usageRepo := &fakeRPUsageQueryRepo{
|
||||
items: []domain.RPUsageDailyMetric{
|
||||
{Date: "2026-05-12", TenantID: "tenant-a", ClientID: "client-owned", ClientName: "Owned App", LoginRequests: 3},
|
||||
{Date: "2026-05-12", TenantID: "tenant-a", ClientID: "client-other", ClientName: "Other App", LoginRequests: 9},
|
||||
},
|
||||
}
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
Keto: mockKeto,
|
||||
RPUsageQueries: usageRepo,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/rp-usage/daily", h.GetRPUsageDaily)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/rp-usage/daily?days=14&period=day", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var res devRPUsageDailyResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
if assert.Len(t, res.Items, 1) {
|
||||
assert.Equal(t, "client-owned", res.Items[0].ClientID)
|
||||
}
|
||||
assert.Equal(t, "tenant-a", usageRepo.query.TenantID)
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
||||
|
||||
1
common/.gitkeep
Normal file
1
common/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
63
common/core/auth/index.ts
Normal file
63
common/core/auth/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const DEFAULT_OIDC_SCOPE = "openid offline_access profile email";
|
||||
export const DEFAULT_OIDC_REDIRECT_PATH = "/auth/callback";
|
||||
|
||||
export type CommonOidcConfigOptions<TUserStore = unknown> = {
|
||||
authority: string;
|
||||
clientId: string;
|
||||
origin?: string;
|
||||
redirectPath?: string;
|
||||
scope?: string;
|
||||
automaticSilentRenew?: boolean;
|
||||
userStore: TUserStore;
|
||||
};
|
||||
|
||||
type CommonOidcRuntimeConfig<TUserStore> = {
|
||||
authority: string;
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
response_type: "code";
|
||||
scope: string;
|
||||
post_logout_redirect_uri: string;
|
||||
popup_redirect_uri: string;
|
||||
userStore: TUserStore;
|
||||
automaticSilentRenew: boolean;
|
||||
};
|
||||
|
||||
export function buildCommonOidcRuntimeConfig<TUserStore>({
|
||||
authority,
|
||||
clientId,
|
||||
origin = window.location.origin,
|
||||
redirectPath = DEFAULT_OIDC_REDIRECT_PATH,
|
||||
scope = DEFAULT_OIDC_SCOPE,
|
||||
automaticSilentRenew = false,
|
||||
userStore,
|
||||
}: CommonOidcConfigOptions<TUserStore>): CommonOidcRuntimeConfig<TUserStore> {
|
||||
const callbackUrl = `${origin}${redirectPath}`;
|
||||
|
||||
return {
|
||||
authority,
|
||||
client_id: clientId,
|
||||
redirect_uri: callbackUrl,
|
||||
response_type: "code",
|
||||
scope,
|
||||
post_logout_redirect_uri: origin,
|
||||
popup_redirect_uri: callbackUrl,
|
||||
userStore,
|
||||
automaticSilentRenew,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCommonUserManagerSettings<
|
||||
TConfig extends {
|
||||
authority?: string;
|
||||
client_id?: string;
|
||||
redirect_uri?: string;
|
||||
},
|
||||
>(config: TConfig) {
|
||||
return {
|
||||
...config,
|
||||
authority: config.authority || "",
|
||||
client_id: config.client_id || "",
|
||||
redirect_uri: config.redirect_uri || "",
|
||||
};
|
||||
}
|
||||
11
common/core/i18n/index.ts
Normal file
11
common/core/i18n/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { createTomlTranslator } from "./loader";
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_STORAGE_KEY,
|
||||
SUPPORTED_LOCALES,
|
||||
type Locale,
|
||||
type TomlObject,
|
||||
type TomlValue,
|
||||
type TranslatorInput,
|
||||
type TranslatorOptions,
|
||||
} from "./types";
|
||||
176
common/core/i18n/loader.ts
Normal file
176
common/core/i18n/loader.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALE_STORAGE_KEY,
|
||||
SUPPORTED_LOCALES,
|
||||
type Locale,
|
||||
type TomlObject,
|
||||
type TomlValue,
|
||||
type TranslatorInput,
|
||||
type TranslatorOptions,
|
||||
} from "./types";
|
||||
|
||||
function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject {
|
||||
const result: TomlObject = { ...base };
|
||||
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
const currentValue = result[key];
|
||||
if (
|
||||
typeof currentValue === "object" &&
|
||||
currentValue !== null &&
|
||||
typeof value === "object" &&
|
||||
value !== null
|
||||
) {
|
||||
result[key] = mergeTomlObjects(currentValue as TomlObject, value);
|
||||
continue;
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function formatTemplate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
options?: TranslatorOptions,
|
||||
): string {
|
||||
const normalizedTemplate = options?.normalizeEscapedNewlines
|
||||
? template.replace(/\\n/g, "\n")
|
||||
: template;
|
||||
|
||||
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 createTomlTranslator(
|
||||
input: TranslatorInput,
|
||||
options?: TranslatorOptions,
|
||||
) {
|
||||
const translations: Record<Locale, TomlObject> = {
|
||||
ko: input.ko
|
||||
.map((raw) => parseToml(raw))
|
||||
.reduce<TomlObject>((merged, current) => mergeTomlObjects(merged, current), {}),
|
||||
en: input.en
|
||||
.map((raw) => parseToml(raw))
|
||||
.reduce<TomlObject>((merged, current) => mergeTomlObjects(merged, current), {}),
|
||||
};
|
||||
|
||||
return function t(
|
||||
key: string,
|
||||
fallback?: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const locale = detectLocale();
|
||||
const value = getValue(translations[locale], key);
|
||||
if (value && value.length > 0) {
|
||||
return formatTemplate(value, vars, options);
|
||||
}
|
||||
return formatTemplate(fallback ?? key, vars, options);
|
||||
};
|
||||
}
|
||||
20
common/core/i18n/types.ts
Normal file
20
common/core/i18n/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const LOCALE_STORAGE_KEY = "locale";
|
||||
export const DEFAULT_LOCALE = "ko";
|
||||
export const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export type TomlValue = string | TomlObject;
|
||||
|
||||
export interface TomlObject {
|
||||
[key: string]: TomlValue;
|
||||
}
|
||||
|
||||
export interface TranslatorOptions {
|
||||
normalizeEscapedNewlines?: boolean;
|
||||
}
|
||||
|
||||
export interface TranslatorInput {
|
||||
en: string[];
|
||||
ko: string[];
|
||||
}
|
||||
7
common/core/query/queryClient.ts
Normal file
7
common/core/query/queryClient.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const queryClientDefaultOptions = {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
} as const;
|
||||
65
common/core/session/index.ts
Normal file
65
common/core/session/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export const DEFAULT_SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
|
||||
export const DEFAULT_SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
||||
|
||||
export type SessionRenewDecisionParams = {
|
||||
expiresAtSec?: number | null;
|
||||
nowMs: number;
|
||||
isEnabled: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isRenewInFlight: boolean;
|
||||
lastAttemptAtMs: number;
|
||||
thresholdMs?: number;
|
||||
throttleMs?: number;
|
||||
};
|
||||
|
||||
function hasRenewPreconditions({
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isRenewInFlight,
|
||||
}: SessionRenewDecisionParams) {
|
||||
return isAuthenticated && !isLoading && !isRenewInFlight;
|
||||
}
|
||||
|
||||
function isRenewWindowOpen({
|
||||
expiresAtSec,
|
||||
nowMs,
|
||||
lastAttemptAtMs,
|
||||
thresholdMs = DEFAULT_SESSION_RENEW_THRESHOLD_MS,
|
||||
throttleMs = DEFAULT_SESSION_RENEW_THROTTLE_MS,
|
||||
}: SessionRenewDecisionParams) {
|
||||
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 shouldAttemptSlidingSessionRenew(
|
||||
params: SessionRenewDecisionParams,
|
||||
) {
|
||||
if (!params.isEnabled || !hasRenewPreconditions(params)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isRenewWindowOpen(params);
|
||||
}
|
||||
|
||||
export function shouldAttemptUnlimitedSessionRenew(
|
||||
params: SessionRenewDecisionParams,
|
||||
) {
|
||||
if (params.isEnabled || !hasRenewPreconditions(params)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isRenewWindowOpen(params);
|
||||
}
|
||||
8
common/core/utils/index.ts
Normal file
8
common/core/utils/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function mergeClassNames(
|
||||
mergeFn: (...classNames: string[]) => string,
|
||||
classNames: string[],
|
||||
) {
|
||||
return mergeFn(...classNames);
|
||||
}
|
||||
|
||||
export * from "./sort";
|
||||
97
common/core/utils/sort.ts
Normal file
97
common/core/utils/sort.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export type SortConfig<Key extends string = string> = {
|
||||
key: Key;
|
||||
direction: SortDirection;
|
||||
};
|
||||
|
||||
export type SortableValue = string | number | boolean | Date | null | undefined;
|
||||
|
||||
export type SortResolver<T> = (item: T) => SortableValue;
|
||||
|
||||
export type SortResolverMap<T, Key extends string = string> = Partial<
|
||||
Record<Key, SortResolver<T>>
|
||||
>;
|
||||
|
||||
function normalizeSortableValue(value: SortableValue) {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.toLocaleLowerCase();
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function compareNullableValues(
|
||||
left: SortableValue,
|
||||
right: SortableValue,
|
||||
direction: SortDirection,
|
||||
) {
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (left === null || left === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (right === null || right === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const normalizedLeft = normalizeSortableValue(left);
|
||||
const normalizedRight = normalizeSortableValue(right);
|
||||
|
||||
if (normalizedLeft === normalizedRight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedLeft === null || normalizedLeft === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (normalizedRight === null || normalizedRight === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const comparison = normalizedLeft < normalizedRight ? -1 : 1;
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
}
|
||||
|
||||
export function toggleSort<Key extends string>(
|
||||
current: SortConfig<Key> | null,
|
||||
key: Key,
|
||||
): SortConfig<Key> {
|
||||
if (current?.key === key && current.direction === "asc") {
|
||||
return { key, direction: "desc" };
|
||||
}
|
||||
|
||||
return { key, direction: "asc" };
|
||||
}
|
||||
|
||||
export function sortItems<T, Key extends string = string>(
|
||||
items: T[],
|
||||
sortConfig: SortConfig<Key> | null,
|
||||
resolverMap: SortResolverMap<T, Key> = {},
|
||||
) {
|
||||
if (!sortConfig) {
|
||||
return [...items];
|
||||
}
|
||||
|
||||
const resolveValue =
|
||||
resolverMap[sortConfig.key] ??
|
||||
((item: T) =>
|
||||
(item as Record<string, SortableValue>)[sortConfig.key] ?? null);
|
||||
|
||||
return [...items].sort((left, right) =>
|
||||
compareNullableValues(
|
||||
resolveValue(left),
|
||||
resolveValue(right),
|
||||
sortConfig.direction,
|
||||
),
|
||||
);
|
||||
}
|
||||
98
common/locales/en.toml
Normal file
98
common/locales/en.toml
Normal file
@@ -0,0 +1,98 @@
|
||||
[msg.common]
|
||||
copied = "Copied."
|
||||
error = "Error"
|
||||
forbidden = "Access denied."
|
||||
loading = "Loading..."
|
||||
no_results = "No results found."
|
||||
no_description = "No Description."
|
||||
parsing = "Parsing data..."
|
||||
requesting = "Requesting..."
|
||||
saving = "Saving..."
|
||||
unknown_error = "unknown error"
|
||||
|
||||
[ui.common]
|
||||
actions = "Actions"
|
||||
add = "Add"
|
||||
all = "All"
|
||||
admin_only = "Admin Only"
|
||||
approve = "Approve"
|
||||
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"
|
||||
continue = "Continue"
|
||||
copy = "Copy"
|
||||
create = "Create"
|
||||
delete = "Delete"
|
||||
detail = "Detail"
|
||||
details = "Details"
|
||||
disabled = "Disabled"
|
||||
edit = "Edit"
|
||||
enabled = "Enabled"
|
||||
export = "Export"
|
||||
export_with_ids = "Include UUID"
|
||||
export_without_ids = "Export without UUID"
|
||||
fail = "Fail"
|
||||
go_home = "Go Home"
|
||||
info = "Info"
|
||||
view = "View"
|
||||
hyphen = "-"
|
||||
loading = "Loading..."
|
||||
manage = "Manage"
|
||||
move = "Move"
|
||||
move_org = "Move to another organization"
|
||||
na = "N/A"
|
||||
never = "Never"
|
||||
next = "Next"
|
||||
none = "None"
|
||||
page_of = "Page {{page}} of {{total}}"
|
||||
prev = "Prev"
|
||||
previous = "Previous"
|
||||
qr = "QR"
|
||||
reject = "Reject"
|
||||
rejected = "Rejected"
|
||||
reset = "Reset"
|
||||
read_only = "Read Only"
|
||||
refresh = "Refresh"
|
||||
remove = "Remove"
|
||||
remove_org = "Remove from organization"
|
||||
resend = "Resend"
|
||||
retry = "Retry"
|
||||
row = "Row"
|
||||
save = "Save"
|
||||
search = "Search"
|
||||
search_group = "Search groups..."
|
||||
select = "Select"
|
||||
select_file = "Select File"
|
||||
select_placeholder = "Select Placeholder"
|
||||
show_more = "Show More"
|
||||
language = "Language"
|
||||
language_ko = "Korean"
|
||||
language_en = "English"
|
||||
submit = "Submit"
|
||||
submitting = "Submitting..."
|
||||
success = "Success"
|
||||
theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
theme_toggle = "Theme Toggle"
|
||||
unassigned = "Unassigned"
|
||||
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"
|
||||
98
common/locales/ko.toml
Normal file
98
common/locales/ko.toml
Normal file
@@ -0,0 +1,98 @@
|
||||
[msg.common]
|
||||
copied = "복사되었습니다."
|
||||
error = "오류가 발생했습니다."
|
||||
forbidden = "접근 권한이 없습니다."
|
||||
loading = "로딩 중..."
|
||||
no_results = "검색 결과가 없습니다."
|
||||
no_description = "설명이 없습니다."
|
||||
parsing = "데이터 파싱 중..."
|
||||
requesting = "요청 중..."
|
||||
saving = "저장 중..."
|
||||
unknown_error = "알 수 없는 오류"
|
||||
|
||||
[ui.common]
|
||||
actions = "액션"
|
||||
add = "추가"
|
||||
all = "전체"
|
||||
admin_only = "관리자 전용"
|
||||
approve = "승인"
|
||||
assign = "할당"
|
||||
back = "돌아가기"
|
||||
back_to_login = "로그인으로 돌아가기"
|
||||
cancel = "취소"
|
||||
change_file = "파일 변경"
|
||||
clear_search = "검색 초기화"
|
||||
close = "닫기"
|
||||
collapse = "접기"
|
||||
confirm = "확인"
|
||||
continue = "계속 진행"
|
||||
copy = "복사"
|
||||
create = "생성"
|
||||
delete = "삭제"
|
||||
detail = "상세보기"
|
||||
details = "상세정보"
|
||||
disabled = "사용 안 함"
|
||||
edit = "편집"
|
||||
enabled = "사용"
|
||||
export = "내보내기"
|
||||
export_with_ids = "UUID 포함"
|
||||
export_without_ids = "UUID 제외 내보내기"
|
||||
fail = "실패"
|
||||
go_home = "홈으로"
|
||||
info = "상세 안내"
|
||||
view = "보기"
|
||||
hyphen = "-"
|
||||
loading = "로딩 중..."
|
||||
manage = "관리"
|
||||
move = "이동"
|
||||
move_org = "타 조직으로 이동"
|
||||
na = "N/A"
|
||||
never = "Never"
|
||||
next = "다음"
|
||||
none = "없음"
|
||||
page_of = "Page {{page}} of {{total}}"
|
||||
prev = "이전"
|
||||
previous = "이전"
|
||||
qr = "QR"
|
||||
reject = "반려"
|
||||
rejected = "반려됨"
|
||||
reset = "초기화"
|
||||
read_only = "읽기 전용"
|
||||
refresh = "새로고침"
|
||||
remove = "제외"
|
||||
remove_org = "조직에서 제외"
|
||||
resend = "재발송"
|
||||
retry = "다시 시도"
|
||||
row = "행"
|
||||
save = "저장"
|
||||
search = "검색"
|
||||
search_group = "그룹 검색..."
|
||||
select = "선택"
|
||||
select_file = "파일 선택"
|
||||
select_placeholder = "선택하세요"
|
||||
show_more = "+ 더보기"
|
||||
language = "언어"
|
||||
language_ko = "한국어"
|
||||
language_en = "English"
|
||||
submit = "신청하기"
|
||||
submitting = "제출 중..."
|
||||
success = "성공"
|
||||
theme_dark = "Dark"
|
||||
theme_light = "Light"
|
||||
theme_toggle = "테마 전환"
|
||||
unassigned = "미배정"
|
||||
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 = "성공"
|
||||
98
common/locales/template.toml
Normal file
98
common/locales/template.toml
Normal file
@@ -0,0 +1,98 @@
|
||||
[msg.common]
|
||||
copied = ""
|
||||
error = ""
|
||||
forbidden = ""
|
||||
loading = ""
|
||||
no_results = ""
|
||||
no_description = ""
|
||||
parsing = ""
|
||||
requesting = ""
|
||||
saving = ""
|
||||
unknown_error = ""
|
||||
|
||||
[ui.common]
|
||||
actions = ""
|
||||
add = ""
|
||||
all = ""
|
||||
admin_only = ""
|
||||
approve = ""
|
||||
assign = ""
|
||||
back = ""
|
||||
back_to_login = ""
|
||||
cancel = ""
|
||||
change_file = ""
|
||||
clear_search = ""
|
||||
close = ""
|
||||
collapse = ""
|
||||
confirm = ""
|
||||
continue = ""
|
||||
copy = ""
|
||||
create = ""
|
||||
delete = ""
|
||||
detail = ""
|
||||
details = ""
|
||||
disabled = ""
|
||||
edit = ""
|
||||
enabled = ""
|
||||
export = ""
|
||||
export_with_ids = ""
|
||||
export_without_ids = ""
|
||||
fail = ""
|
||||
go_home = ""
|
||||
info = ""
|
||||
view = ""
|
||||
hyphen = ""
|
||||
loading = ""
|
||||
manage = ""
|
||||
move = ""
|
||||
move_org = ""
|
||||
na = ""
|
||||
never = ""
|
||||
next = ""
|
||||
none = ""
|
||||
page_of = ""
|
||||
prev = ""
|
||||
previous = ""
|
||||
qr = ""
|
||||
reject = ""
|
||||
rejected = ""
|
||||
reset = ""
|
||||
read_only = ""
|
||||
refresh = ""
|
||||
remove = ""
|
||||
remove_org = ""
|
||||
resend = ""
|
||||
retry = ""
|
||||
row = ""
|
||||
save = ""
|
||||
search = ""
|
||||
search_group = ""
|
||||
select = ""
|
||||
select_file = ""
|
||||
select_placeholder = ""
|
||||
show_more = ""
|
||||
language = ""
|
||||
language_ko = ""
|
||||
language_en = ""
|
||||
submit = ""
|
||||
submitting = ""
|
||||
success = ""
|
||||
theme_dark = ""
|
||||
theme_light = ""
|
||||
theme_toggle = ""
|
||||
unassigned = ""
|
||||
unknown = ""
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = ""
|
||||
command_only = ""
|
||||
system = ""
|
||||
|
||||
[ui.common.status]
|
||||
active = ""
|
||||
blocked = ""
|
||||
failure = ""
|
||||
inactive = ""
|
||||
ok = ""
|
||||
pending = ""
|
||||
success = ""
|
||||
170
common/shell/index.ts
Normal file
170
common/shell/index.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
export type ShellTheme = "light" | "dark";
|
||||
|
||||
export type ShellTranslator = (
|
||||
key: string,
|
||||
fallback: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
type ShellSessionStatusParams = {
|
||||
expiresAtSec?: number | null;
|
||||
nowMs: number;
|
||||
t: ShellTranslator;
|
||||
};
|
||||
|
||||
type ShellProfileSummaryParams = {
|
||||
profileName?: string | null;
|
||||
profileEmail?: string | null;
|
||||
fallbackName: string;
|
||||
fallbackEmail: string;
|
||||
};
|
||||
|
||||
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
|
||||
export const SHELL_SESSION_EXPIRY_STORAGE_KEY =
|
||||
"baron_session_expiry_enabled";
|
||||
|
||||
export const shellLayoutClasses = {
|
||||
root: "grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]",
|
||||
aside:
|
||||
"flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur",
|
||||
asideStatic:
|
||||
"border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur",
|
||||
brandSection:
|
||||
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
|
||||
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
|
||||
brandIcon:
|
||||
"grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]",
|
||||
scopeBadge:
|
||||
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
|
||||
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
|
||||
navMeta:
|
||||
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
|
||||
navList: "flex flex-col gap-1",
|
||||
navItemBase:
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
navItemActive:
|
||||
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
|
||||
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
sidebarFooterNotice:
|
||||
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
|
||||
logoutButton:
|
||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||
header: "sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerElevated:
|
||||
"sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
|
||||
headerTitleWrap: "flex flex-col gap-1",
|
||||
headerActions: "flex items-center gap-2 text-sm",
|
||||
actionButton:
|
||||
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||
sessionBadge:
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
profileInitial:
|
||||
"grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary",
|
||||
profileMenu:
|
||||
"absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl",
|
||||
profileCard:
|
||||
"mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3",
|
||||
settingsCard: "mt-2 rounded-lg border border-border px-3 py-3",
|
||||
content: "relative",
|
||||
contentWide: "relative min-w-0",
|
||||
main: "px-5 py-6 md:px-10 md:py-10",
|
||||
mainMinWidth: "min-w-0 px-5 py-6 md:px-10 md:py-10",
|
||||
} as const;
|
||||
|
||||
export function readShellTheme(): ShellTheme {
|
||||
return window.localStorage.getItem(SHELL_THEME_STORAGE_KEY) === "dark"
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
export function applyShellTheme(theme: ShellTheme) {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(theme);
|
||||
window.localStorage.setItem(SHELL_THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
export function readShellSessionExpiryEnabled() {
|
||||
return window.localStorage.getItem(SHELL_SESSION_EXPIRY_STORAGE_KEY) !== "false";
|
||||
}
|
||||
|
||||
export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
|
||||
window.localStorage.setItem(
|
||||
SHELL_SESSION_EXPIRY_STORAGE_KEY,
|
||||
String(isEnabled),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildShellProfileSummary({
|
||||
profileName,
|
||||
profileEmail,
|
||||
fallbackName,
|
||||
fallbackEmail,
|
||||
}: ShellProfileSummaryParams) {
|
||||
const resolvedName = profileName?.trim() || fallbackName;
|
||||
const resolvedEmail = profileEmail?.trim() || fallbackEmail;
|
||||
|
||||
return {
|
||||
name: resolvedName,
|
||||
email: resolvedEmail,
|
||||
initial: resolvedName.charAt(0).toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildShellSessionStatus({
|
||||
expiresAtSec,
|
||||
nowMs,
|
||||
t,
|
||||
}: ShellSessionStatusParams) {
|
||||
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 toneClass =
|
||||
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
let text = t("ui.dev.session.active", "세션 활성");
|
||||
|
||||
if (remainingMs === null) {
|
||||
toneClass = "border-border bg-card text-muted-foreground";
|
||||
text = t("ui.dev.session.unknown", "알 수 없음");
|
||||
} else if (remainingMs <= 0) {
|
||||
toneClass =
|
||||
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
|
||||
text = t("ui.dev.session.expired", "세션 만료");
|
||||
} else if (
|
||||
remainingMinutes !== null &&
|
||||
remainingSeconds !== null &&
|
||||
remainingMinutes <= 5
|
||||
) {
|
||||
toneClass =
|
||||
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
|
||||
text = t(
|
||||
"ui.dev.session.expiring",
|
||||
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes,
|
||||
seconds: remainingSeconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
text = t(
|
||||
"ui.dev.session.remaining",
|
||||
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
|
||||
{
|
||||
minutes: remainingMinutes ?? 0,
|
||||
seconds: remainingSeconds ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
toneClass,
|
||||
text,
|
||||
};
|
||||
}
|
||||
64
common/theme/base.css
Normal file
64
common/theme/base.css
Normal file
@@ -0,0 +1,64 @@
|
||||
@layer base {
|
||||
:root.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%;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--background: 210 25% 6%;
|
||||
--foreground: 210 35% 96%;
|
||||
--card: 215 32% 9%;
|
||||
--card-foreground: 210 35% 96%;
|
||||
--popover: 215 32% 9%;
|
||||
--popover-foreground: 210 35% 96%;
|
||||
--primary: 209 79% 52%;
|
||||
--primary-foreground: 210 35% 96%;
|
||||
--secondary: 215 25% 16%;
|
||||
--secondary-foreground: 210 35% 96%;
|
||||
--muted: 215 15% 65%;
|
||||
--muted-foreground: 215 15% 65%;
|
||||
--accent: 42 95% 57%;
|
||||
--accent-foreground: 215 25% 10%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 35% 96%;
|
||||
--border: 215 25% 24%;
|
||||
--input: 215 25% 24%;
|
||||
--ring: 209 79% 52%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||
background-image: var(--app-background-image, none);
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-inherit no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-panel {
|
||||
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
|
||||
}
|
||||
}
|
||||
26
common/ui/badge.ts
Normal file
26
common/ui/badge.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const commonBadgeBaseClass =
|
||||
"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";
|
||||
|
||||
export const commonBadgeVariantClasses = {
|
||||
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",
|
||||
} as const;
|
||||
|
||||
export type CommonBadgeVariant = keyof typeof commonBadgeVariantClasses;
|
||||
|
||||
export function getCommonBadgeClasses({
|
||||
variant = "default",
|
||||
}: {
|
||||
variant?: CommonBadgeVariant;
|
||||
}) {
|
||||
return [commonBadgeBaseClass, commonBadgeVariantClasses[variant]].join(" ");
|
||||
}
|
||||
37
common/ui/button.ts
Normal file
37
common/ui/button.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const commonButtonBaseClass =
|
||||
"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";
|
||||
|
||||
export const commonButtonVariantClasses = {
|
||||
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",
|
||||
} as const;
|
||||
|
||||
export const commonButtonSizeClasses = {
|
||||
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",
|
||||
} as const;
|
||||
|
||||
export type CommonButtonVariant = keyof typeof commonButtonVariantClasses;
|
||||
export type CommonButtonSize = keyof typeof commonButtonSizeClasses;
|
||||
|
||||
export function getCommonButtonClasses({
|
||||
variant = "default",
|
||||
size = "default",
|
||||
}: {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
}) {
|
||||
return [
|
||||
commonButtonBaseClass,
|
||||
commonButtonVariantClasses[variant],
|
||||
commonButtonSizeClasses[size],
|
||||
].join(" ");
|
||||
}
|
||||
7
common/ui/card.ts
Normal file
7
common/ui/card.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const commonCardClass =
|
||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card";
|
||||
export const commonCardHeaderClass = "flex flex-col space-y-1.5 p-6";
|
||||
export const commonCardTitleClass = "text-lg font-semibold leading-none";
|
||||
export const commonCardDescriptionClass = "text-sm text-muted-foreground";
|
||||
export const commonCardContentClass = "p-6 pt-0";
|
||||
export const commonCardFooterClass = "flex items-center p-6 pt-0";
|
||||
2
common/ui/input.ts
Normal file
2
common/ui/input.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const commonInputClass =
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
Navigate,
|
||||
type RouteObject,
|
||||
createBrowserRouter,
|
||||
} from "react-router-dom";
|
||||
import { type RouteObject, createBrowserRouter } from "react-router-dom";
|
||||
import AppLayout from "../components/layout/AppLayout";
|
||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||
@@ -13,6 +9,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
||||
import ClientsPage from "../features/clients/ClientsPage";
|
||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
|
||||
import ProfilePage from "../features/profile/ProfilePage";
|
||||
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||
@@ -33,7 +30,7 @@ export const devFrontRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "clients", element: <ClientsPage /> },
|
||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
ClipboardCheck,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Moon,
|
||||
NotebookTabs,
|
||||
@@ -20,10 +21,25 @@ 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";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
labelKey: "ui.dev.nav.overview",
|
||||
labelFallback: "Overview",
|
||||
to: "/",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
labelKey: "ui.dev.nav.clients",
|
||||
labelFallback: "Clients",
|
||||
@@ -52,15 +68,11 @@ function AppLayout() {
|
||||
const isRenewInFlightRef = useRef(false);
|
||||
const lastRenewAttemptAtRef = useRef(0);
|
||||
const lastVisitedRouteRef = useRef<string | null>(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);
|
||||
@@ -82,14 +94,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(() => {
|
||||
@@ -265,84 +270,41 @@ 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<string, unknown> | undefined,
|
||||
);
|
||||
const displayRoleKey = profile?.role || 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", "Session active");
|
||||
|
||||
if (remainingMs === null) {
|
||||
sessionToneClass = "border-border bg-card text-muted-foreground";
|
||||
sessionText = t("ui.dev.session.unknown", "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", "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",
|
||||
"Expiring soon: {{minutes}}m {{seconds}}s left",
|
||||
{
|
||||
minutes: remainingMinutes,
|
||||
seconds: remainingSeconds,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
sessionText = t(
|
||||
"ui.dev.session.remaining",
|
||||
"Expires in {{minutes}}m {{seconds}}s",
|
||||
{
|
||||
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 (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<aside className={shellLayoutClasses.aside}>
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -354,28 +316,29 @@ function AppLayout() {
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||
<div className={shellLayoutClasses.scopeBadge}>
|
||||
<BadgeCheck size={14} />
|
||||
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||
<nav className={shellLayoutClasses.navWrap}>
|
||||
<div className={shellLayoutClasses.navMeta}>
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
{t("ui.dev.env_badge", "Env: dev")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
shellLayoutClasses.navItemBase,
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
@@ -392,13 +355,13 @@ function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
|
||||
<div className={shellLayoutClasses.sidebarFooterNotice}>
|
||||
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
|
||||
<p>
|
||||
{t(
|
||||
@@ -410,10 +373,10 @@ function AppLayout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={shellLayoutClasses.content}>
|
||||
<header className={shellLayoutClasses.header}>
|
||||
<div className={shellLayoutClasses.headerInner}>
|
||||
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.dev.header.plane", "Dev Plane")}
|
||||
</p>
|
||||
@@ -421,12 +384,12 @@ function AppLayout() {
|
||||
{t("ui.dev.header.subtitle", "Manage your applications")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={shellLayoutClasses.headerActions}>
|
||||
<LanguageSelector />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
className={shellLayoutClasses.actionButton}
|
||||
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -437,11 +400,11 @@ function AppLayout() {
|
||||
{isSessionExpiryEnabled ? (
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
shellLayoutClasses.sessionBadge,
|
||||
sessionStatus.toneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
{sessionStatus.text}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
@@ -456,15 +419,15 @@ function AppLayout() {
|
||||
"Open account menu",
|
||||
)}
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{profileInitial}
|
||||
<div className={shellLayoutClasses.profileInitial}>
|
||||
{profileSummary.initial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -473,20 +436,17 @@ function AppLayout() {
|
||||
/>
|
||||
</button>
|
||||
{isProfileMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||
>
|
||||
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.profileCard}>
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
@@ -499,7 +459,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.settingsCard}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
@@ -507,7 +467,7 @@ function AppLayout() {
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
? sessionStatus.text
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"Session expiry disabled",
|
||||
@@ -563,7 +523,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<main className={shellLayoutClasses.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CommonBadgeVariant;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<div
|
||||
className={cn(getCommonBadgeClasses({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge };
|
||||
|
||||
@@ -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<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -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<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -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<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
className={cn(commonInputClass, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -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<SortConfig<ClientSortKey> | null>(null);
|
||||
|
||||
const clients = data?.items || [];
|
||||
const clientSortResolvers = useMemo<
|
||||
SortResolverMap<ClientSummary, ClientSortKey>
|
||||
>(
|
||||
() => ({
|
||||
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 <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
|
||||
}
|
||||
|
||||
return sortConfig.direction === "asc" ? (
|
||||
<ArrowUp className="ml-1 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 h-4 w-4" />
|
||||
);
|
||||
};
|
||||
|
||||
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
@@ -389,18 +452,50 @@ function ClientsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.application", "애플리케이션")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("application")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.dev.clients.table.application", "애플리케이션")}
|
||||
{getSortIcon("application")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.client_id", "Client ID")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.dev.clients.table.client_id", "Client ID")}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.status", "상태")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("type")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.dev.clients.table.type", "유형")}
|
||||
{getSortIcon("type")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.dev.clients.table.created_at", "생성일")}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.dev.clients.table.status", "상태")}
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("createdAt")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.dev.clients.table.created_at", "생성일")}
|
||||
{getSortIcon("createdAt")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.dev.clients.table.actions", "액션")}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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<RPUsageDailyResponse>(
|
||||
"/dev/rp-usage/daily",
|
||||
{
|
||||
params: { days, period },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchTenants(
|
||||
limit = 1000,
|
||||
offset = 0,
|
||||
|
||||
@@ -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<Locale, TomlObject> = {
|
||||
ko: parseToml(koRaw),
|
||||
en: parseToml(enRaw),
|
||||
};
|
||||
|
||||
function formatTemplate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): 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, string | number>,
|
||||
): 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],
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
10
docs/i18n.md
10
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) 구현 가이드
|
||||
* **패키지 설치**:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<string | null>(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<string, unknown> | 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 (
|
||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
||||
<aside className="flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<aside className={shellLayoutClasses.aside}>
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>
|
||||
<ShieldHalf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -348,18 +304,18 @@ function AppLayout() {
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
|
||||
<div className={shellLayoutClasses.scopeBadge}>
|
||||
<BadgeCheck size={14} />
|
||||
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
||||
<nav className={shellLayoutClasses.navWrap}>
|
||||
<div className={shellLayoutClasses.navMeta}>
|
||||
<span className="rounded-full border border-border px-3 py-1">
|
||||
{t("ui.dev.env_badge", "Env: dev")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{isDevConsoleAllowed &&
|
||||
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
@@ -367,10 +323,10 @@ function AppLayout() {
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
shellLayoutClasses.navItemBase,
|
||||
isActive
|
||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
@@ -387,13 +343,13 @@ function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
|
||||
<div className={shellLayoutClasses.sidebarFooterNotice}>
|
||||
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
|
||||
<p>
|
||||
{t(
|
||||
@@ -405,10 +361,10 @@ function AppLayout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={shellLayoutClasses.content}>
|
||||
<header className={shellLayoutClasses.header}>
|
||||
<div className={shellLayoutClasses.headerInner}>
|
||||
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("ui.dev.header.plane", "Dev Plane")}
|
||||
</p>
|
||||
@@ -416,12 +372,12 @@ function AppLayout() {
|
||||
{t("ui.dev.header.subtitle", "Manage your applications")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={shellLayoutClasses.headerActions}>
|
||||
<LanguageSelector />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
|
||||
className={shellLayoutClasses.actionButton}
|
||||
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||
>
|
||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||
@@ -432,11 +388,11 @@ function AppLayout() {
|
||||
{isSessionExpiryEnabled ? (
|
||||
<span
|
||||
className={[
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
sessionToneClass,
|
||||
shellLayoutClasses.sessionBadge,
|
||||
sessionStatus.toneClass,
|
||||
].join(" ")}
|
||||
>
|
||||
{sessionText}
|
||||
{sessionStatus.text}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
@@ -448,15 +404,15 @@ function AppLayout() {
|
||||
aria-expanded={isProfileMenuOpen}
|
||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
||||
>
|
||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
||||
{profileInitial}
|
||||
<div className={shellLayoutClasses.profileInitial}>
|
||||
{profileSummary.initial}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left md:block">
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -465,20 +421,17 @@ function AppLayout() {
|
||||
/>
|
||||
</button>
|
||||
{isProfileMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
||||
>
|
||||
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{t("ui.dev.profile.menu_title", "Account")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.profileCard}>
|
||||
<div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{profileName}
|
||||
{profileSummary.name}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profileEmail}
|
||||
{profileSummary.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center pt-1">
|
||||
@@ -491,7 +444,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-border px-3 py-3">
|
||||
<div className={shellLayoutClasses.settingsCard}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
@@ -499,7 +452,7 @@ function AppLayout() {
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSessionExpiryEnabled
|
||||
? sessionText
|
||||
? sessionStatus.text
|
||||
: t(
|
||||
"ui.dev.session.disabled",
|
||||
"세션 만료 비활성화",
|
||||
@@ -555,7 +508,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
||||
<main className={shellLayoutClasses.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CommonBadgeVariant;
|
||||
}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<div
|
||||
className={cn(getCommonBadgeClasses({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
export { Badge };
|
||||
|
||||
@@ -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<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -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<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
);
|
||||
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -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<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
className={cn(commonInputClass, className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "../../common/theme/base.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -24,72 +26,10 @@
|
||||
--input: 220 17% 90%;
|
||||
--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%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 210 25% 6%;
|
||||
--foreground: 210 35% 96%;
|
||||
--card: 215 32% 9%;
|
||||
--card-foreground: 210 35% 96%;
|
||||
--popover: 215 32% 9%;
|
||||
--popover-foreground: 210 35% 96%;
|
||||
--primary: 209 79% 52%;
|
||||
--primary-foreground: 210 35% 96%;
|
||||
--secondary: 215 25% 16%;
|
||||
--secondary-foreground: 210 35% 96%;
|
||||
--muted: 215 15% 65%;
|
||||
--muted-foreground: 215 15% 65%;
|
||||
--accent: 42 95% 57%;
|
||||
--accent-foreground: 215 25% 10%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 35% 96%;
|
||||
--border: 215 25% 24%;
|
||||
--input: 215 25% 24%;
|
||||
--ring: 209 79% 52%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||
background-image: linear-gradient(
|
||||
--app-background-image: linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--background)) 0%,
|
||||
hsl(var(--secondary) / 0.35) 100%
|
||||
);
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-inherit no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-panel {
|
||||
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||
import type { AuthProviderProps } from "react-oidc-context";
|
||||
import {
|
||||
buildOrgFrontAuthRedirectUris,
|
||||
resolveOrgFrontPublicOrigin,
|
||||
} from "./authConfig";
|
||||
buildCommonOidcRuntimeConfig,
|
||||
buildCommonUserManagerSettings,
|
||||
} from "../../../common/core/auth";
|
||||
import { resolveOrgFrontPublicOrigin } from "./authConfig";
|
||||
|
||||
const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin(
|
||||
import.meta.env.VITE_ORGFRONT_PUBLIC_URL,
|
||||
window.location.origin,
|
||||
);
|
||||
const orgFrontRedirectUris =
|
||||
buildOrgFrontAuthRedirectUris(orgFrontPublicOrigin);
|
||||
|
||||
export const oidcConfig: AuthProviderProps = {
|
||||
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
||||
authority:
|
||||
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
|
||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
|
||||
redirect_uri: orgFrontRedirectUris.redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||
post_logout_redirect_uri: orgFrontRedirectUris.postLogoutRedirectUri,
|
||||
popup_redirect_uri: orgFrontRedirectUris.popupRedirectUri,
|
||||
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc",
|
||||
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
|
||||
origin: orgFrontPublicOrigin,
|
||||
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),
|
||||
);
|
||||
|
||||
@@ -1,148 +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<Locale, TomlObject> = {
|
||||
ko: parseToml(koRaw),
|
||||
en: parseToml(enRaw),
|
||||
};
|
||||
|
||||
function formatTemplate(
|
||||
template: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
if (!vars) {
|
||||
return template;
|
||||
}
|
||||
return template.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, string | number>,
|
||||
): 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],
|
||||
});
|
||||
|
||||
@@ -1,76 +1,27 @@
|
||||
import {
|
||||
DEFAULT_SESSION_RENEW_THROTTLE_MS,
|
||||
shouldAttemptSlidingSessionRenew as shouldAttemptSlidingSessionRenewBase,
|
||||
shouldAttemptUnlimitedSessionRenew as shouldAttemptUnlimitedSessionRenewBase,
|
||||
type SessionRenewDecisionParams,
|
||||
} from "../../../common/core/session";
|
||||
|
||||
export const SESSION_RENEW_THROTTLE_MS = DEFAULT_SESSION_RENEW_THROTTLE_MS;
|
||||
export const SESSION_RENEW_THRESHOLD_MS = 5 * 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 shouldAttemptSlidingSessionRenew(
|
||||
params: SessionRenewDecisionParams,
|
||||
) {
|
||||
return shouldAttemptSlidingSessionRenewBase({
|
||||
...params,
|
||||
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
|
||||
});
|
||||
}
|
||||
|
||||
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 function shouldAttemptUnlimitedSessionRenew(
|
||||
params: SessionRenewDecisionParams,
|
||||
) {
|
||||
return shouldAttemptUnlimitedSessionRenewBase({
|
||||
...params,
|
||||
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -302,15 +302,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?"
|
||||
|
||||
@@ -1176,73 +1167,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"
|
||||
edit = "Edit"
|
||||
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"
|
||||
|
||||
|
||||
@@ -302,15 +302,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 = "로그아웃 하시겠습니까?"
|
||||
|
||||
@@ -1178,73 +1169,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 = "상세정보"
|
||||
edit = "편집"
|
||||
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 = "테스트"
|
||||
|
||||
|
||||
@@ -302,15 +302,6 @@ no_custom = ""
|
||||
[msg.admin.users.list.registry]
|
||||
count = ""
|
||||
|
||||
[msg.common]
|
||||
error = ""
|
||||
loading = ""
|
||||
no_description = ""
|
||||
parsing = ""
|
||||
requesting = ""
|
||||
saving = ""
|
||||
unknown_error = ""
|
||||
|
||||
[msg.dev]
|
||||
logout_confirm = ""
|
||||
|
||||
@@ -1177,73 +1168,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 = ""
|
||||
edit = ""
|
||||
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 = ""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,8 +27,12 @@ if command -v rsync >/dev/null 2>&1; then
|
||||
--exclude 'playwright-report' \
|
||||
--exclude 'test-results' \
|
||||
"$repo_root/adminfront/" "$tmp_dir/adminfront/"
|
||||
rsync -rlptD --delete \
|
||||
--exclude 'node_modules' \
|
||||
"$repo_root/common/" "$tmp_dir/common/"
|
||||
else
|
||||
cp -R "$repo_root/adminfront" "$tmp_dir/adminfront"
|
||||
cp -R "$repo_root/common" "$tmp_dir/common"
|
||||
rm -rf "$tmp_dir/adminfront/node_modules" \
|
||||
"$tmp_dir/adminfront/playwright-report" \
|
||||
"$tmp_dir/adminfront/test-results"
|
||||
|
||||
@@ -5,11 +5,27 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT_DIR = process.cwd();
|
||||
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
|
||||
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
|
||||
const LANG_FILES = ['ko.toml', 'en.toml'];
|
||||
const FAIL_UNUSED = process.argv.includes('--fail-unused');
|
||||
|
||||
const LOCALE_SPECS = [
|
||||
{
|
||||
name: 'root',
|
||||
label: 'root locales',
|
||||
dir: path.join(ROOT_DIR, 'locales'),
|
||||
template: 'template.toml',
|
||||
langs: ['ko.toml', 'en.toml'],
|
||||
ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'),
|
||||
},
|
||||
{
|
||||
name: 'common',
|
||||
label: 'common locales',
|
||||
dir: path.join(ROOT_DIR, 'common', 'locales'),
|
||||
template: 'template.toml',
|
||||
langs: ['ko.toml', 'en.toml'],
|
||||
ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'),
|
||||
},
|
||||
];
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
@@ -78,7 +94,6 @@ function parseTomlKeys(filePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip quotes if present
|
||||
if (key.startsWith('"') && key.endsWith('"')) {
|
||||
key = key.slice(1, -1);
|
||||
}
|
||||
@@ -138,6 +153,23 @@ function collectCodeKeys() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function shouldIgnoreCodeKey(key) {
|
||||
return (
|
||||
key.includes('.msg.') ||
|
||||
key.includes('.ui.') ||
|
||||
key.includes('.err.') ||
|
||||
key.includes('.test.') ||
|
||||
key.includes('.non.') ||
|
||||
key.startsWith('ui.admin.users.list.table.') ||
|
||||
key.startsWith('msg.admin.users.detail.') ||
|
||||
key.startsWith('msg.dev.clients.') ||
|
||||
key.startsWith('ui.admin.users.create.') ||
|
||||
key.startsWith('ui.admin.users.detail.') ||
|
||||
key.startsWith('ui.dev.clients.') ||
|
||||
key.startsWith('ui.dev.session.')
|
||||
);
|
||||
}
|
||||
|
||||
function difference(aSet, bSet) {
|
||||
const result = [];
|
||||
for (const item of aSet) {
|
||||
@@ -158,72 +190,75 @@ function printList(title, items) {
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const templateResult = parseTomlKeys(TEMPLATE_PATH);
|
||||
function collectSpecResources(spec) {
|
||||
const templatePath = path.join(spec.dir, spec.template);
|
||||
const templateResult = parseTomlKeys(templatePath);
|
||||
if (!templateResult.ok) {
|
||||
errors.push(templateResult.error);
|
||||
return { ok: false, error: templateResult.error };
|
||||
}
|
||||
|
||||
const langKeyMap = new Map();
|
||||
for (const fileName of LANG_FILES) {
|
||||
const langPath = path.join(LOCALES_DIR, fileName);
|
||||
for (const fileName of spec.langs) {
|
||||
const langPath = path.join(spec.dir, fileName);
|
||||
const langResult = parseTomlKeys(langPath);
|
||||
if (!langResult.ok) {
|
||||
errors.push(langResult.error);
|
||||
continue;
|
||||
return { ok: false, error: langResult.error };
|
||||
}
|
||||
langKeyMap.set(fileName, langResult.keys);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.');
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
templateKeys: templateResult.keys,
|
||||
langKeyMap,
|
||||
};
|
||||
}
|
||||
|
||||
const templateKeys = templateResult.keys;
|
||||
const rawCodeKeys = Array.from(collectCodeKeys());
|
||||
const codeKeysArray = rawCodeKeys.filter(k =>
|
||||
!k.includes('.msg.') &&
|
||||
!k.includes('.ui.') &&
|
||||
!k.includes('.err.') &&
|
||||
!k.includes('.test.') &&
|
||||
!k.includes('.non.') &&
|
||||
!k.startsWith("ui.admin.users.list.table.") &&
|
||||
!k.startsWith("msg.admin.users.detail.") &&
|
||||
!k.startsWith("msg.common.") &&
|
||||
!k.startsWith("msg.dev.clients.") &&
|
||||
!k.startsWith("ui.admin.users.create.") &&
|
||||
!k.startsWith("ui.admin.users.detail.") &&
|
||||
!k.startsWith("ui.common.") &&
|
||||
!k.startsWith("ui.dev.clients.") &&
|
||||
!k.startsWith("ui.dev.session.")
|
||||
function main() {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const rawCodeKeys = Array.from(collectCodeKeys()).filter(
|
||||
(key) => !shouldIgnoreCodeKey(key),
|
||||
);
|
||||
const codeKeys = new Set(codeKeysArray);
|
||||
const codeKeys = new Set(rawCodeKeys);
|
||||
|
||||
for (const [fileName, langKeys] of langKeyMap.entries()) {
|
||||
const missingInLang = difference(templateKeys, langKeys);
|
||||
if (missingInLang.length > 0) {
|
||||
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`);
|
||||
printList(`${fileName}에 없는 키`, missingInLang);
|
||||
for (const spec of LOCALE_SPECS) {
|
||||
const resources = collectSpecResources(spec);
|
||||
if (!resources.ok) {
|
||||
errors.push(resources.error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const missingInTemplate = difference(codeKeys, templateKeys);
|
||||
if (missingInTemplate.length > 0) {
|
||||
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`);
|
||||
printList('template.toml에 없는 코드 사용 키', missingInTemplate);
|
||||
}
|
||||
for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
|
||||
const missingInLang = difference(resources.templateKeys, langKeys);
|
||||
if (missingInLang.length > 0) {
|
||||
errors.push(
|
||||
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`,
|
||||
);
|
||||
printList(`${spec.label} ${fileName}에 없는 키`, missingInLang);
|
||||
}
|
||||
}
|
||||
|
||||
const unusedInTemplate = difference(templateKeys, codeKeys);
|
||||
if (unusedInTemplate.length > 0) {
|
||||
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`);
|
||||
printList('코드에서 사용되지 않는 키', unusedInTemplate);
|
||||
const ownedCodeKeys = new Set(
|
||||
rawCodeKeys.filter((key) => spec.ownsKey(key)),
|
||||
);
|
||||
|
||||
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
|
||||
if (missingInTemplate.length > 0) {
|
||||
errors.push(
|
||||
`[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}개`,
|
||||
);
|
||||
printList(`${spec.label} template.toml에 없는 코드 사용 키`, missingInTemplate);
|
||||
}
|
||||
|
||||
const unusedInTemplate = difference(resources.templateKeys, codeKeys);
|
||||
if (unusedInTemplate.length > 0) {
|
||||
warnings.push(
|
||||
`[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}개`,
|
||||
);
|
||||
printList(`${spec.label} 코드에서 사용되지 않는 키`, unusedInTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
|
||||
@@ -5,9 +5,25 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT_DIR = process.cwd();
|
||||
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
|
||||
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
|
||||
const LANG_FILES = ['ko.toml', 'en.toml'];
|
||||
|
||||
const LOCALE_SPECS = [
|
||||
{
|
||||
name: 'root',
|
||||
label: 'root locales',
|
||||
dir: path.join(ROOT_DIR, 'locales'),
|
||||
template: 'template.toml',
|
||||
langs: ['ko.toml', 'en.toml'],
|
||||
ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'),
|
||||
},
|
||||
{
|
||||
name: 'common',
|
||||
label: 'common locales',
|
||||
dir: path.join(ROOT_DIR, 'common', 'locales'),
|
||||
template: 'template.toml',
|
||||
langs: ['ko.toml', 'en.toml'],
|
||||
ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'),
|
||||
},
|
||||
];
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
'.git',
|
||||
@@ -81,7 +97,6 @@ function parseTomlKeys(filePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip quotes if present
|
||||
if (key.startsWith('"') && key.endsWith('"')) {
|
||||
key = key.slice(1, -1);
|
||||
}
|
||||
@@ -141,22 +156,20 @@ function collectCodeKeys() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function filterCodeKeys(rawKeys) {
|
||||
return Array.from(rawKeys).filter((k) =>
|
||||
!k.includes('.msg.') &&
|
||||
!k.includes('.ui.') &&
|
||||
!k.includes('.err.') &&
|
||||
!k.includes('.test.') &&
|
||||
!k.includes('.non.') &&
|
||||
!k.startsWith('ui.admin.users.list.table.') &&
|
||||
!k.startsWith('msg.admin.users.detail.') &&
|
||||
!k.startsWith('msg.common.') &&
|
||||
!k.startsWith('msg.dev.clients.') &&
|
||||
!k.startsWith('ui.admin.users.create.') &&
|
||||
!k.startsWith('ui.admin.users.detail.') &&
|
||||
!k.startsWith('ui.common.') &&
|
||||
!k.startsWith('ui.dev.clients.') &&
|
||||
!k.startsWith('ui.dev.session.')
|
||||
function shouldIgnoreCodeKey(key) {
|
||||
return (
|
||||
key.includes('.msg.') ||
|
||||
key.includes('.ui.') ||
|
||||
key.includes('.err.') ||
|
||||
key.includes('.test.') ||
|
||||
key.includes('.non.') ||
|
||||
key.startsWith('ui.admin.users.list.table.') ||
|
||||
key.startsWith('msg.admin.users.detail.') ||
|
||||
key.startsWith('msg.dev.clients.') ||
|
||||
key.startsWith('ui.admin.users.create.') ||
|
||||
key.startsWith('ui.admin.users.detail.') ||
|
||||
key.startsWith('ui.dev.clients.') ||
|
||||
key.startsWith('ui.dev.session.')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,62 +183,82 @@ function difference(aSet, bSet) {
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function collectSpecResources(spec) {
|
||||
const templatePath = path.join(spec.dir, spec.template);
|
||||
const templateResult = parseTomlKeys(templatePath);
|
||||
if (!templateResult.ok) {
|
||||
return { ok: false, error: templateResult.error };
|
||||
}
|
||||
|
||||
const langKeyMap = new Map();
|
||||
for (const fileName of spec.langs) {
|
||||
const langPath = path.join(spec.dir, fileName);
|
||||
const langResult = parseTomlKeys(langPath);
|
||||
if (!langResult.ok) {
|
||||
return { ok: false, error: langResult.error };
|
||||
}
|
||||
langKeyMap.set(fileName, langResult.keys);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
templateKeys: templateResult.keys,
|
||||
langKeyMap,
|
||||
};
|
||||
}
|
||||
|
||||
function buildReport() {
|
||||
const report = {
|
||||
generated_at: new Date().toISOString(),
|
||||
errors: [],
|
||||
warnings: [],
|
||||
details: {
|
||||
details: {},
|
||||
};
|
||||
|
||||
const rawCodeKeys = Array.from(collectCodeKeys()).filter(
|
||||
(key) => !shouldIgnoreCodeKey(key),
|
||||
);
|
||||
const codeKeys = new Set(rawCodeKeys);
|
||||
|
||||
for (const spec of LOCALE_SPECS) {
|
||||
const resources = collectSpecResources(spec);
|
||||
report.details[spec.name] = {
|
||||
missing_in_template: [],
|
||||
missing_in_lang: {},
|
||||
unused_in_template: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const templateResult = parseTomlKeys(TEMPLATE_PATH);
|
||||
if (!templateResult.ok) {
|
||||
report.errors.push(templateResult.error);
|
||||
return report;
|
||||
}
|
||||
|
||||
const templateKeys = templateResult.keys;
|
||||
const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
|
||||
|
||||
const langKeyMap = new Map();
|
||||
for (const fileName of LANG_FILES) {
|
||||
const langPath = path.join(LOCALES_DIR, fileName);
|
||||
const langResult = parseTomlKeys(langPath);
|
||||
if (!langResult.ok) {
|
||||
report.errors.push(langResult.error);
|
||||
if (!resources.ok) {
|
||||
report.errors.push(resources.error);
|
||||
continue;
|
||||
}
|
||||
langKeyMap.set(fileName, langResult.keys);
|
||||
}
|
||||
|
||||
for (const [fileName, langKeys] of langKeyMap.entries()) {
|
||||
const missingInLang = difference(templateKeys, langKeys);
|
||||
if (missingInLang.length > 0) {
|
||||
report.errors.push(
|
||||
`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`,
|
||||
);
|
||||
report.details.missing_in_lang[fileName] = missingInLang;
|
||||
for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
|
||||
const missingInLang = difference(resources.templateKeys, langKeys);
|
||||
if (missingInLang.length > 0) {
|
||||
report.errors.push(
|
||||
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`,
|
||||
);
|
||||
report.details[spec.name].missing_in_lang[fileName] = missingInLang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missingInTemplate = difference(codeKeys, templateKeys);
|
||||
if (missingInTemplate.length > 0) {
|
||||
report.errors.push(
|
||||
`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`,
|
||||
);
|
||||
report.details.missing_in_template = missingInTemplate;
|
||||
}
|
||||
const ownedCodeKeys = new Set(rawCodeKeys.filter((key) => spec.ownsKey(key)));
|
||||
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
|
||||
if (missingInTemplate.length > 0) {
|
||||
report.errors.push(
|
||||
`[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}개`,
|
||||
);
|
||||
report.details[spec.name].missing_in_template = missingInTemplate;
|
||||
}
|
||||
|
||||
const unusedInTemplate = difference(templateKeys, codeKeys);
|
||||
if (unusedInTemplate.length > 0) {
|
||||
report.warnings.push(
|
||||
`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`,
|
||||
);
|
||||
report.details.unused_in_template = unusedInTemplate;
|
||||
const unusedInTemplate = difference(resources.templateKeys, codeKeys);
|
||||
if (unusedInTemplate.length > 0) {
|
||||
report.warnings.push(
|
||||
`[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}개`,
|
||||
);
|
||||
report.details[spec.name].unused_in_template = unusedInTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
@@ -258,8 +291,12 @@ function main() {
|
||||
fs.writeFileSync(summaryPath, lines.join('\n'));
|
||||
|
||||
if (report.errors.length > 0) {
|
||||
console.error('❌ i18n report generated with errors');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ i18n report written to ${outPath}`);
|
||||
console.log(`✅ i18n summary written to ${summaryPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -95,10 +95,14 @@ async function mockUserfrontApis(
|
||||
pendingRef?: string;
|
||||
};
|
||||
pendingRef = body.pendingRef ?? null;
|
||||
} catch (_) {
|
||||
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve POST body:`, body);
|
||||
} catch (e) {
|
||||
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve POST body parse error:`, e);
|
||||
pendingRef = null;
|
||||
}
|
||||
options.captureApprove?.(pendingRef);
|
||||
} else {
|
||||
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/web_window.dart';
|
||||
|
||||
class ApproveQrScreen extends StatefulWidget {
|
||||
final String? pendingRef;
|
||||
@@ -60,7 +61,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final target = buildLocalizedSigninPath(Uri.base);
|
||||
context.go('$target?notice=qr_login_required');
|
||||
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -102,7 +103,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
if (storedToken == null && !hasCookie) {
|
||||
if (mounted) {
|
||||
final target = buildLocalizedSigninPath(Uri.base);
|
||||
context.go('$target?notice=qr_login_required');
|
||||
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,20 @@ Map<String, dynamic>? _decodeErrorDetails(String? raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _hasActiveLocalSession() {
|
||||
final token = AuthTokenStore.getToken();
|
||||
return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||
}
|
||||
|
||||
String? _redirectPrivateLocaleRoute(GoRouterState state) {
|
||||
if (_hasActiveLocalSession()) {
|
||||
return null;
|
||||
}
|
||||
final localeCode =
|
||||
extractLocaleFromPath(state.uri) ?? resolvePreferredLocaleCode();
|
||||
return buildSigninRedirectPath(localeCode, state.uri);
|
||||
}
|
||||
|
||||
void _attemptRecoveryFromNullCheck({
|
||||
required Object exception,
|
||||
StackTrace? stackTrace,
|
||||
@@ -243,25 +257,18 @@ final _router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/:locale',
|
||||
redirect: (context, state) {
|
||||
// /{locale} 진입은 화면 렌더링 없이 단일 목적지로만 보냅니다.
|
||||
if (state.uri.pathSegments.length != 1) {
|
||||
return null;
|
||||
}
|
||||
builder: (context, state) {
|
||||
final rawLocale = state.pathParameters['locale'];
|
||||
final localeCode = normalizeLocaleCode(rawLocale);
|
||||
final token = AuthTokenStore.getToken();
|
||||
final isLoggedIn =
|
||||
(token != null && token.isNotEmpty) ||
|
||||
AuthTokenStore.usesCookie();
|
||||
if (!isLoggedIn) {
|
||||
return buildSigninRedirectPath(localeCode, state.uri);
|
||||
}
|
||||
return '/$localeCode/dashboard';
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: LocaleEntryRedirectScreen(localeCode: localeCode),
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'dashboard',
|
||||
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||
builder: (context, state) {
|
||||
return ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
@@ -271,6 +278,7 @@ final _router = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: 'profile',
|
||||
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
child: const ProfilePage(),
|
||||
@@ -430,6 +438,19 @@ final _router = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: 'approve',
|
||||
redirect: (context, state) {
|
||||
final token = AuthTokenStore.getToken();
|
||||
final isLoggedIn =
|
||||
(token != null && token.isNotEmpty) ||
|
||||
AuthTokenStore.usesCookie();
|
||||
if (isLoggedIn) {
|
||||
return null;
|
||||
}
|
||||
final localeCode =
|
||||
extractLocaleFromPath(state.uri) ??
|
||||
resolvePreferredLocaleCode();
|
||||
return '/$localeCode/signin?notice=qr_login_required';
|
||||
},
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ApproveQrScreen(
|
||||
@@ -439,6 +460,17 @@ final _router = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: 'ql/:ref',
|
||||
redirect: (context, state) {
|
||||
final localeCode =
|
||||
extractLocaleFromPath(state.uri) ??
|
||||
resolvePreferredLocaleCode();
|
||||
final pendingRef = state.pathParameters['ref'];
|
||||
if (pendingRef == null || pendingRef.isEmpty) {
|
||||
return '/$localeCode/approve';
|
||||
}
|
||||
final encodedRef = Uri.encodeQueryComponent(pendingRef);
|
||||
return '/$localeCode/approve?ref=$encodedRef';
|
||||
},
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
||||
@@ -446,6 +478,7 @@ final _router = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: 'scan',
|
||||
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.auth,
|
||||
child: const QRScanScreen(),
|
||||
@@ -453,6 +486,7 @@ final _router = GoRouter(
|
||||
),
|
||||
GoRoute(
|
||||
path: 'admin/users',
|
||||
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||
builder: (context, state) => ScopedTheme(
|
||||
controller: ThemeController.app,
|
||||
child: const UserManagementScreen(),
|
||||
@@ -478,6 +512,10 @@ final _router = GoRouter(
|
||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||
final path = stripLocalePath(uri);
|
||||
|
||||
if (!isLoggedIn && (path == '/approve' || path.startsWith('/ql/'))) {
|
||||
return '/$requestedLocale/signin?notice=qr_login_required';
|
||||
}
|
||||
|
||||
final isPublicPath = isPublicAuthPath(path, uri);
|
||||
|
||||
if (isPublicPath) {
|
||||
@@ -499,9 +537,24 @@ final _router = GoRouter(
|
||||
},
|
||||
);
|
||||
|
||||
class BaronSSOApp extends StatelessWidget {
|
||||
class BaronSSOApp extends StatefulWidget {
|
||||
const BaronSSOApp({super.key});
|
||||
|
||||
@override
|
||||
State<BaronSSOApp> createState() => _BaronSSOAppState();
|
||||
}
|
||||
|
||||
class _BaronSSOAppState extends State<BaronSSOApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Re-run router redirects after the first frame so session-only web
|
||||
// storage state is reflected even when startup routing evaluated too early.
|
||||
AuthNotifier.instance.notify();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localization = EasyLocalization.of(context);
|
||||
@@ -531,3 +584,49 @@ class BaronSSOApp extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LocaleEntryRedirectScreen extends StatefulWidget {
|
||||
const LocaleEntryRedirectScreen({super.key, required this.localeCode});
|
||||
|
||||
final String localeCode;
|
||||
|
||||
@override
|
||||
State<LocaleEntryRedirectScreen> createState() =>
|
||||
_LocaleEntryRedirectScreenState();
|
||||
}
|
||||
|
||||
class _LocaleEntryRedirectScreenState extends State<LocaleEntryRedirectScreen> {
|
||||
bool _redirected = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_redirect();
|
||||
});
|
||||
}
|
||||
|
||||
void _redirect() {
|
||||
if (!mounted || _redirected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This parent route is also built for nested locale routes, so only
|
||||
// redirect when the current location is exactly `/{locale}`.
|
||||
if (stripLocalePath(Uri.base) != '/') {
|
||||
return;
|
||||
}
|
||||
|
||||
_redirected = true;
|
||||
if (!_hasActiveLocalSession()) {
|
||||
context.go('/${widget.localeCode}/signin');
|
||||
return;
|
||||
}
|
||||
context.go('/${widget.localeCode}/dashboard');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +45,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -276,6 +276,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -328,18 +336,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -661,26 +669,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.30.0"
|
||||
version: "1.26.3"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.7"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.16"
|
||||
version: "0.6.12"
|
||||
toml:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user