forked from baron/baron-sso
Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -541,11 +541,12 @@ KETO_WRITE_URL = "http://keto:4467"
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 🌐 i18n 구조 (간략)
|
## 🌐 i18n 구조 (간략)
|
||||||
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
|
- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
|
||||||
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
|
- **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`에 사전 생성합니다.
|
- **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`과 런타임 번역 리소스를 동기화합니다.
|
- **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
|
## 🧪 Code Check CI
|
||||||
워크플로우 파일: `.gitea/workflows/code_check.yml`
|
워크플로우 파일: `.gitea/workflows/code_check.yml`
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: queryClientDefaultOptions,
|
||||||
queries: {
|
|
||||||
staleTime: 30_000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ import {
|
|||||||
shouldAttemptSlidingSessionRenew,
|
shouldAttemptSlidingSessionRenew,
|
||||||
shouldAttemptUnlimitedSessionRenew,
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
} from "../../lib/sessionSliding";
|
} from "../../lib/sessionSliding";
|
||||||
|
import {
|
||||||
|
applyShellTheme,
|
||||||
|
buildShellProfileSummary,
|
||||||
|
buildShellSessionStatus,
|
||||||
|
readShellSessionExpiryEnabled,
|
||||||
|
readShellTheme,
|
||||||
|
shellLayoutClasses,
|
||||||
|
writeShellSessionExpiryEnabled,
|
||||||
|
} from "../../../../common/shell";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
@@ -62,15 +71,11 @@ function AppLayout() {
|
|||||||
const mockRoleOverride = isMockRoleEnabled
|
const mockRoleOverride = isMockRoleEnabled
|
||||||
? window.localStorage.getItem("X-Mock-Role")
|
? window.localStorage.getItem("X-Mock-Role")
|
||||||
: null;
|
: null;
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
|
||||||
return stored === "dark" ? "dark" : "light";
|
|
||||||
});
|
|
||||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
||||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
readShellSessionExpiryEnabled,
|
||||||
return stored !== "false";
|
);
|
||||||
});
|
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -214,14 +219,7 @@ function AppLayout() {
|
|||||||
}, [auth.user]);
|
}, [auth.user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
applyShellTheme(theme);
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
if (theme === "light") {
|
|
||||||
root.classList.add("light");
|
|
||||||
} else {
|
|
||||||
root.classList.add("dark");
|
|
||||||
}
|
|
||||||
window.localStorage.setItem("admin_theme", theme);
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -388,68 +386,26 @@ function AppLayout() {
|
|||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileName =
|
const profileSummary = buildShellProfileSummary({
|
||||||
profile?.name?.trim() ||
|
profileName:
|
||||||
auth.user?.profile.name?.toString().trim() ||
|
profile?.name ||
|
||||||
auth.user?.profile.preferred_username?.toString().trim() ||
|
auth.user?.profile.name?.toString() ||
|
||||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
auth.user?.profile.preferred_username?.toString(),
|
||||||
const profileEmail =
|
profileEmail: profile?.email || auth.user?.profile.email?.toString(),
|
||||||
profile?.email?.trim() ||
|
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
|
||||||
auth.user?.profile.email?.toString().trim() ||
|
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
|
||||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
});
|
||||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
|
||||||
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
||||||
const expiresAtSec = auth.user?.expires_at;
|
const sessionStatus = buildShellSessionStatus({
|
||||||
const remainingMs =
|
expiresAtSec: auth.user?.expires_at,
|
||||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
nowMs,
|
||||||
const remainingTotalSec =
|
t,
|
||||||
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 handleSessionExpiryToggle = () => {
|
const handleSessionExpiryToggle = () => {
|
||||||
setIsSessionExpiryEnabled((prev) => {
|
setIsSessionExpiryEnabled((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
writeShellSessionExpiryEnabled(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -463,11 +419,11 @@ function AppLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<div className={shellLayoutClasses.root}>
|
||||||
<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">
|
<aside className={shellLayoutClasses.asideStatic}>
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
<div className={shellLayoutClasses.brandSection}>
|
||||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
<div className={shellLayoutClasses.brandWrap}>
|
||||||
<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.brandIcon}>
|
||||||
<ShieldHalf size={20} />
|
<ShieldHalf size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -480,8 +436,8 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
<nav className={shellLayoutClasses.navWrap}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className={shellLayoutClasses.navList}>
|
||||||
{navItems.map((item: NavItem) => {
|
{navItems.map((item: NavItem) => {
|
||||||
const { label, to, icon: Icon, isExternal } = item;
|
const { label, to, icon: Icon, isExternal } = item;
|
||||||
const isOrgChart = location.pathname === "/tenants/org-chart";
|
const isOrgChart = location.pathname === "/tenants/org-chart";
|
||||||
@@ -499,7 +455,10 @@ function AppLayout() {
|
|||||||
href={to}
|
href={to}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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} />
|
<Icon size={18} />
|
||||||
<span>{t(label, label)}</span>
|
<span>{t(label, label)}</span>
|
||||||
@@ -513,10 +472,10 @@ function AppLayout() {
|
|||||||
to={to}
|
to={to}
|
||||||
className={() =>
|
className={() =>
|
||||||
[
|
[
|
||||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
shellLayoutClasses.navItemBase,
|
||||||
isCustomActive
|
isCustomActive
|
||||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
? shellLayoutClasses.navItemActive
|
||||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
: shellLayoutClasses.navItemIdle,
|
||||||
].join(" ")
|
].join(" ")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -531,7 +490,7 @@ function AppLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
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} />
|
<LogOut size={18} />
|
||||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||||
@@ -540,10 +499,10 @@ function AppLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="relative min-w-0">
|
<div className={shellLayoutClasses.contentWide}>
|
||||||
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
|
<header className={shellLayoutClasses.headerElevated}>
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
<div className={shellLayoutClasses.headerInner}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
{t("ui.admin.header.plane", "ADMIN PLANE")}
|
||||||
</p>
|
</p>
|
||||||
@@ -552,12 +511,12 @@ function AppLayout() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className={shellLayoutClasses.headerActions}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleTheme}
|
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", "테마 전환")}
|
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||||
>
|
>
|
||||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
@@ -568,11 +527,11 @@ function AppLayout() {
|
|||||||
{isSessionExpiryEnabled ? (
|
{isSessionExpiryEnabled ? (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
shellLayoutClasses.sessionBadge,
|
||||||
sessionToneClass,
|
sessionStatus.toneClass,
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{sessionText}
|
{sessionStatus.text}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative" ref={profileMenuRef}>
|
<div className="relative" ref={profileMenuRef}>
|
||||||
@@ -584,15 +543,15 @@ function AppLayout() {
|
|||||||
aria-expanded={isProfileOpen}
|
aria-expanded={isProfileOpen}
|
||||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
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">
|
<div className={shellLayoutClasses.profileInitial}>
|
||||||
{profileInitial}
|
{profileSummary.initial}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden min-w-0 text-left md:block">
|
<div className="hidden min-w-0 text-left md:block">
|
||||||
<p className="truncate text-xs font-medium text-foreground">
|
<p className="truncate text-xs font-medium text-foreground">
|
||||||
{profileName}
|
{profileSummary.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] text-muted-foreground">
|
<p className="truncate text-[11px] text-muted-foreground">
|
||||||
{profileEmail}
|
{profileSummary.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -602,20 +561,17 @@ function AppLayout() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isProfileOpen ? (
|
{isProfileOpen ? (
|
||||||
<div
|
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||||
role="menu"
|
|
||||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
|
||||||
>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{t("ui.dev.profile.menu_title", "Account")}
|
{t("ui.dev.profile.menu_title", "Account")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
<div className={shellLayoutClasses.profileCard}>
|
||||||
<div>
|
<div>
|
||||||
<p className="truncate text-sm font-semibold text-foreground">
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
{profileName}
|
{profileSummary.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{profileEmail}
|
{profileSummary.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center pt-1">
|
<div className="flex items-center pt-1">
|
||||||
@@ -628,7 +584,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</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 className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
@@ -636,7 +592,7 @@ function AppLayout() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isSessionExpiryEnabled
|
{isSessionExpiryEnabled
|
||||||
? sessionText
|
? sessionStatus.text
|
||||||
: t(
|
: t(
|
||||||
"ui.dev.session.disabled",
|
"ui.dev.session.disabled",
|
||||||
"세션 만료 비활성화",
|
"세션 만료 비활성화",
|
||||||
@@ -736,7 +692,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
|
<main className={shellLayoutClasses.mainMinWidth}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<RoleSwitcher />
|
<RoleSwitcher />
|
||||||
|
|||||||
@@ -1,38 +1,21 @@
|
|||||||
import { type VariantProps, cva } from "class-variance-authority";
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import {
|
||||||
|
type CommonBadgeVariant,
|
||||||
|
getCommonBadgeClasses,
|
||||||
|
} from "../../../../common/ui/badge";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
"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",
|
variant?: CommonBadgeVariant;
|
||||||
{
|
}
|
||||||
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> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
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 { Slot } from "@radix-ui/react-slot";
|
||||||
import { type VariantProps, cva } from "class-variance-authority";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
type CommonButtonSize,
|
||||||
|
type CommonButtonVariant,
|
||||||
|
getCommonButtonClasses,
|
||||||
|
} from "../../../../common/ui/button";
|
||||||
import { cn } from "../../lib/utils";
|
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
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
VariantProps<typeof buttonVariants> {
|
variant?: CommonButtonVariant;
|
||||||
|
size?: CommonButtonSize;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button };
|
||||||
|
|||||||
@@ -1,65 +1,51 @@
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import {
|
||||||
|
commonCardClass,
|
||||||
|
commonCardContentClass,
|
||||||
|
commonCardDescriptionClass,
|
||||||
|
commonCardFooterClass,
|
||||||
|
commonCardHeaderClass,
|
||||||
|
commonCardTitleClass,
|
||||||
|
} from "../../../../common/ui/card";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({
|
function CardHeader({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({
|
function CardTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
return (
|
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||||
<h3
|
|
||||||
className={cn("text-lg font-semibold leading-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({
|
function CardDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
return (
|
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({
|
function CardContent({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({
|
function CardFooter({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { commonInputClass } from "../../../../common/ui/input";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(commonInputClass, className)}
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
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 { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
@@ -82,10 +88,7 @@ import {
|
|||||||
const tenantCSVTemplate =
|
const tenantCSVTemplate =
|
||||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
||||||
|
|
||||||
type SortConfig = {
|
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
|
||||||
key: keyof TenantSummary | "recursiveMemberCount";
|
|
||||||
direction: "asc" | "desc";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTenantIcon = (type?: string) => {
|
const getTenantIcon = (type?: string) => {
|
||||||
switch (type?.toUpperCase()) {
|
switch (type?.toUpperCase()) {
|
||||||
@@ -225,7 +228,8 @@ function TenantListPage() {
|
|||||||
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
||||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
const [search, setSearch] = React.useState("");
|
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 fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [importMessage, setImportMessage] = React.useState("");
|
const [importMessage, setImportMessage] = React.useState("");
|
||||||
const [previewRows, setPreviewRows] = React.useState<
|
const [previewRows, setPreviewRows] = React.useState<
|
||||||
@@ -363,6 +367,17 @@ function TenantListPage() {
|
|||||||
const allTenants = query.data?.items ?? [];
|
const allTenants = query.data?.items ?? [];
|
||||||
const importParentOptionGroups =
|
const importParentOptionGroups =
|
||||||
buildTenantImportParentOptionGroups(allTenants);
|
buildTenantImportParentOptionGroups(allTenants);
|
||||||
|
const tenantSortResolvers = React.useMemo<
|
||||||
|
SortResolverMap<
|
||||||
|
TenantSummary & { recursiveMemberCount: number },
|
||||||
|
TenantSortKey
|
||||||
|
>
|
||||||
|
>(
|
||||||
|
() => ({
|
||||||
|
recursiveMemberCount: (tenant) => tenant.recursiveMemberCount,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
const tenants = React.useMemo(() => {
|
const tenants = React.useMemo(() => {
|
||||||
// 1. Calculate recursive counts
|
// 1. Calculate recursive counts
|
||||||
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
||||||
@@ -396,38 +411,14 @@ function TenantListPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sortConfig) {
|
return sortItems(enriched, sortConfig, tenantSortResolvers);
|
||||||
enriched.sort((a, b) => {
|
}, [allTenants, search, sortConfig, tenantSortResolvers]);
|
||||||
const aValue = a[sortConfig.key as keyof typeof a];
|
|
||||||
const bValue = b[sortConfig.key as keyof typeof b];
|
|
||||||
|
|
||||||
if (aValue === bValue) return 0;
|
const requestSort = (key: TenantSortKey) => {
|
||||||
if (aValue === null || aValue === undefined) return 1;
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
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 getSortIcon = (key: SortConfig["key"]) => {
|
const getSortIcon = (key: TenantSortKey) => {
|
||||||
if (!sortConfig || sortConfig.key !== key) {
|
if (!sortConfig || sortConfig.key !== key) {
|
||||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
sortItems,
|
||||||
|
toggleSort,
|
||||||
|
type SortConfig,
|
||||||
|
type SortResolverMap,
|
||||||
|
} from "../../../../common/core/utils";
|
||||||
import {
|
import {
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
bulkDeleteUsers,
|
bulkDeleteUsers,
|
||||||
@@ -71,10 +77,7 @@ type UserSchemaField = {
|
|||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SortConfig = {
|
type UserSortKey = string;
|
||||||
key: string;
|
|
||||||
direction: "asc" | "desc";
|
|
||||||
};
|
|
||||||
|
|
||||||
function UserListPage() {
|
function UserListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -86,7 +89,8 @@ function UserListPage() {
|
|||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
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 limit = 1000;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -219,60 +223,40 @@ function UserListPage() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const rawItems = query.data?.items ?? [];
|
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 items = React.useMemo(() => {
|
||||||
const sorted = [...rawItems];
|
return sortItems(rawItems, sortConfig, userSortResolvers);
|
||||||
if (sortConfig) {
|
}, [rawItems, sortConfig, userSortResolvers]);
|
||||||
sorted.sort((a, b) => {
|
|
||||||
let aValue: string | number | boolean | null | undefined;
|
|
||||||
let bValue: string | number | boolean | null | undefined;
|
|
||||||
|
|
||||||
if (sortConfig.key === "name_email") {
|
const requestSort = (key: UserSortKey) => {
|
||||||
aValue = a.name?.toLowerCase() || "";
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
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 getSortIcon = (key: SortConfig["key"]) => {
|
const getSortIcon = (key: UserSortKey) => {
|
||||||
if (!sortConfig || sortConfig.key !== key) {
|
if (!sortConfig || sortConfig.key !== key) {
|
||||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "../../common/theme/base.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -24,37 +26,7 @@
|
|||||||
--input: 215 25% 24%;
|
--input: 215 25% 24%;
|
||||||
--ring: 209 79% 52%;
|
--ring: 209 79% 52%;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
}
|
--app-background-image: radial-gradient(
|
||||||
|
|
||||||
.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(
|
|
||||||
circle at 10% 18%,
|
circle at 10% 18%,
|
||||||
rgba(54, 211, 153, 0.16),
|
rgba(54, 211, 153, 0.16),
|
||||||
transparent 28%
|
transparent 28%
|
||||||
@@ -70,14 +42,4 @@
|
|||||||
transparent 30%
|
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 { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
import type { AuthProviderProps } from "react-oidc-context";
|
import type { AuthProviderProps } from "react-oidc-context";
|
||||||
import {
|
import {
|
||||||
buildAdminAuthRedirectUris,
|
buildCommonOidcRuntimeConfig,
|
||||||
resolveAdminPublicOrigin,
|
buildCommonUserManagerSettings,
|
||||||
} from "./authConfig";
|
} from "../../../common/core/auth";
|
||||||
|
import { resolveAdminPublicOrigin } from "./authConfig";
|
||||||
|
|
||||||
const adminPublicOrigin = resolveAdminPublicOrigin(
|
const adminPublicOrigin = resolveAdminPublicOrigin(
|
||||||
import.meta.env.VITE_ADMIN_PUBLIC_URL,
|
import.meta.env.VITE_ADMIN_PUBLIC_URL,
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const adminRedirectUris = buildAdminAuthRedirectUris(adminPublicOrigin);
|
|
||||||
|
|
||||||
export const oidcConfig: AuthProviderProps = {
|
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
||||||
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
|
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
|
||||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
|
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
|
||||||
redirect_uri: adminRedirectUris.redirectUri,
|
origin: adminPublicOrigin,
|
||||||
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,
|
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
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";
|
import { createTomlTranslator } from "../../../common/core/i18n";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import enRaw from "../locales/en.toml?raw";
|
import enRaw from "../locales/en.toml?raw";
|
||||||
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import koRaw from "../locales/ko.toml?raw";
|
import koRaw from "../locales/ko.toml?raw";
|
||||||
|
|
||||||
const translations: Record<Locale, TomlObject> = {
|
export const t = createTomlTranslator(
|
||||||
ko: parseToml(koRaw),
|
{
|
||||||
en: parseToml(enRaw),
|
ko: [commonKoRaw, koRaw],
|
||||||
};
|
en: [commonEnRaw, enRaw],
|
||||||
|
},
|
||||||
function formatTemplate(
|
{
|
||||||
template: string,
|
normalizeEscapedNewlines: true,
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,106 +1,6 @@
|
|||||||
export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
|
export {
|
||||||
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
|
||||||
|
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
|
||||||
type SlidingSessionRenewDecisionParams = {
|
shouldAttemptSlidingSessionRenew,
|
||||||
expiresAtSec?: number | null;
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
nowMs: number;
|
} from "../../../common/core/session";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
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 { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { mergeClassNames } from "../../../common/core/utils";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return mergeClassNames(twMerge, [clsx(inputs)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateSecurePassword(length = 16): string {
|
export function generateSecurePassword(length = 16): string {
|
||||||
|
|||||||
@@ -327,15 +327,6 @@ no_custom = "No custom fields defined for this tenant."
|
|||||||
[msg.admin.users.list.registry]
|
[msg.admin.users.list.registry]
|
||||||
count = "Count"
|
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]
|
[msg.dev]
|
||||||
logout_confirm = "Are you sure you want to log out?"
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
|
|
||||||
@@ -1281,63 +1272,6 @@ name = "Name"
|
|||||||
role = "Role"
|
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]
|
[ui.common.role]
|
||||||
admin = "Admin"
|
admin = "Admin"
|
||||||
rp_admin = "RP Admin"
|
rp_admin = "RP Admin"
|
||||||
|
|||||||
@@ -329,15 +329,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다."
|
|||||||
[msg.admin.users.list.registry]
|
[msg.admin.users.list.registry]
|
||||||
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
|
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
|
||||||
|
|
||||||
[msg.common]
|
|
||||||
error = "오류가 발생했습니다."
|
|
||||||
loading = "로딩 중..."
|
|
||||||
no_description = "설명이 없습니다."
|
|
||||||
parsing = "데이터 파싱 중..."
|
|
||||||
requesting = "요청 중..."
|
|
||||||
saving = "저장 중..."
|
|
||||||
unknown_error = "알 수 없는 오류"
|
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = "로그아웃 하시겠습니까?"
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
|
|
||||||
@@ -1283,63 +1274,6 @@ name = "이름"
|
|||||||
role = "역할"
|
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]
|
[ui.common.role]
|
||||||
admin = "Admin"
|
admin = "Admin"
|
||||||
rp_admin = "RP Admin"
|
rp_admin = "RP Admin"
|
||||||
|
|||||||
@@ -337,15 +337,6 @@ no_custom = ""
|
|||||||
[msg.admin.users.list.registry]
|
[msg.admin.users.list.registry]
|
||||||
count = ""
|
count = ""
|
||||||
|
|
||||||
[msg.common]
|
|
||||||
error = ""
|
|
||||||
loading = ""
|
|
||||||
no_description = ""
|
|
||||||
parsing = ""
|
|
||||||
requesting = ""
|
|
||||||
saving = ""
|
|
||||||
unknown_error = ""
|
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = ""
|
logout_confirm = ""
|
||||||
|
|
||||||
@@ -1261,63 +1252,6 @@ name = ""
|
|||||||
role = ""
|
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]
|
[ui.common.role]
|
||||||
admin = ""
|
admin = ""
|
||||||
rp_admin = ""
|
rp_admin = ""
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme";
|
|||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
|
"../common/**/*.{ts,tsx,css}",
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
center: true,
|
center: true,
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ func main() {
|
|||||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||||
devHandler.AuditRepo = auditRepo
|
devHandler.AuditRepo = auditRepo
|
||||||
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
|
||||||
|
devHandler.RPUsageQueries = rpUsageQueryRepo
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
|
||||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
@@ -825,6 +826,7 @@ func main() {
|
|||||||
dev.Get("/consents", devHandler.ListConsents)
|
dev.Get("/consents", devHandler.ListConsents)
|
||||||
dev.Delete("/consents", devHandler.RevokeConsents)
|
dev.Delete("/consents", devHandler.RevokeConsents)
|
||||||
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
dev.Get("/audit-logs", devHandler.ListAuditLogs)
|
||||||
|
dev.Get("/rp-usage/daily", devHandler.GetRPUsageDaily)
|
||||||
|
|
||||||
// [New] Developer Registration Flow
|
// [New] Developer Registration Flow
|
||||||
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type DevHandler struct {
|
|||||||
TenantSvc service.TenantService
|
TenantSvc service.TenantService
|
||||||
DeveloperSvc *service.DeveloperService
|
DeveloperSvc *service.DeveloperService
|
||||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||||
|
RPUsageQueries domain.RPUsageQueryRepository
|
||||||
Auth interface {
|
Auth interface {
|
||||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||||
}
|
}
|
||||||
@@ -94,6 +95,13 @@ type devStatsResponse struct {
|
|||||||
AuthFailures int64 `json:"auth_failures_24h"`
|
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 {
|
type clientSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -369,16 +377,12 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
|
|||||||
if role == domain.RoleSuperAdmin {
|
if role == domain.RoleSuperAdmin {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if canAccessClientByLegacyScope(profile, summary) {
|
||||||
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
clientTenantID := resolveClientTenantID(summary)
|
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
||||||
if role != domain.RoleUser && clientTenantID != "" {
|
return true
|
||||||
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
|
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
|
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 {
|
func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
return false
|
return false
|
||||||
@@ -1009,6 +1033,70 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
|
|||||||
return allowed, nil
|
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) {
|
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
|
||||||
authHeader = strings.TrimSpace(authHeader)
|
authHeader = strings.TrimSpace(authHeader)
|
||||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||||
@@ -1133,72 +1221,22 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
if profile == nil {
|
items, err := h.listVisibleClientSummaries(c, profile, limit, offset)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to check app manager permission", "error", err)
|
status := fiber.StatusInternalServerError
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
var fiberErr *fiber.Error
|
||||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
|
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)
|
return errorJSON(c, status, 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 c.JSON(clientListResponse{
|
return c.JSON(clientListResponse{
|
||||||
@@ -2547,59 +2585,42 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
func (h *DevHandler) GetStats(c *fiber.Ctx) error {
|
||||||
h.injectTenantContextFromHeader(c)
|
h.injectTenantContextFromHeader(c)
|
||||||
|
profile := h.getCurrentProfile(c)
|
||||||
// [Security] Check permission
|
if profile == nil {
|
||||||
allowed, err := h.checkAppManagerPermission(c)
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||||
if err != nil {
|
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
|
||||||
}
|
}
|
||||||
if !allowed {
|
role := normalizeUserRole(profile.Role)
|
||||||
|
if !isDevConsoleViewerRole(role) {
|
||||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||||
}
|
}
|
||||||
|
|
||||||
userTenantID := ""
|
visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0)
|
||||||
isSuperAdmin := false
|
if err != nil {
|
||||||
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients")
|
||||||
isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin
|
|
||||||
if profile.TenantID != nil {
|
|
||||||
userTenantID = *profile.TenantID
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Total Clients (Tenant Scoped)
|
userTenantID := tenantIDFromProfile(profile)
|
||||||
// Hydra doesn't support tenant filtering natively, so we list and filter.
|
totalClients := int64(len(visibleClients))
|
||||||
// For stats, we might want to fetch a larger batch or use a cached count.
|
visibleClientIDs := clientIDSetFromSummaries(visibleClients)
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Auth Failures (24h)
|
// 2. Auth Failures (24h)
|
||||||
var authFailures int64
|
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
|
var activeSessions int64
|
||||||
if h.AuditRepo != nil {
|
if h.AuditRepo != nil {
|
||||||
since := time.Now().Add(-1 * time.Hour)
|
failureSince := time.Now().Add(-24 * time.Hour)
|
||||||
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID)
|
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{
|
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) {
|
func generateRandomSecret(length int) (string, error) {
|
||||||
bytes := make([]byte, length)
|
bytes := make([]byte, length)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
@@ -3262,6 +3352,74 @@ func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string {
|
|||||||
return ""
|
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.
|
// ListMyTenants returns the list of tenants the current user manages or belongs to.
|
||||||
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
||||||
profile, err := h.Auth.GetEnrichedProfile(c)
|
profile, err := h.Auth.GetEnrichedProfile(c)
|
||||||
|
|||||||
@@ -942,7 +942,6 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mockKeto := new(devMockKetoService)
|
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)
|
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil)
|
||||||
|
|
||||||
h := &DevHandler{
|
h := &DevHandler{
|
||||||
@@ -1368,6 +1367,22 @@ func TestGetStats_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mockKeto := new(devMockKetoService)
|
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{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{
|
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")
|
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) {
|
func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
||||||
h := &DevHandler{
|
h := &DevHandler{
|
||||||
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
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 { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: queryClientDefaultOptions,
|
||||||
queries: {
|
|
||||||
staleTime: 30_000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { type RouteObject, createBrowserRouter } from "react-router-dom";
|
||||||
Navigate,
|
|
||||||
type RouteObject,
|
|
||||||
createBrowserRouter,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import AppLayout from "../components/layout/AppLayout";
|
import AppLayout from "../components/layout/AppLayout";
|
||||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||||
@@ -13,6 +9,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage";
|
|||||||
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
|
||||||
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
|
||||||
import ClientsPage from "../features/clients/ClientsPage";
|
import ClientsPage from "../features/clients/ClientsPage";
|
||||||
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
|
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
|
||||||
import ProfilePage from "../features/profile/ProfilePage";
|
import ProfilePage from "../features/profile/ProfilePage";
|
||||||
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
|
||||||
@@ -33,7 +30,7 @@ export const devFrontRoutes: RouteObject[] = [
|
|||||||
{
|
{
|
||||||
element: <AppLayout />,
|
element: <AppLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/clients" replace /> },
|
{ index: true, element: <DashboardPage /> },
|
||||||
{ path: "clients", element: <ClientsPage /> },
|
{ path: "clients", element: <ClientsPage /> },
|
||||||
{ path: "clients/new", element: <ClientGeneralPage /> },
|
{ path: "clients/new", element: <ClientGeneralPage /> },
|
||||||
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
{ path: "clients/:id", element: <ClientDetailsPage /> },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
NotebookTabs,
|
NotebookTabs,
|
||||||
@@ -20,10 +21,25 @@ import {
|
|||||||
shouldAttemptSlidingSessionRenew,
|
shouldAttemptSlidingSessionRenew,
|
||||||
shouldAttemptUnlimitedSessionRenew,
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
} from "../../lib/sessionSliding";
|
} from "../../lib/sessionSliding";
|
||||||
|
import {
|
||||||
|
applyShellTheme,
|
||||||
|
buildShellProfileSummary,
|
||||||
|
buildShellSessionStatus,
|
||||||
|
readShellSessionExpiryEnabled,
|
||||||
|
readShellTheme,
|
||||||
|
shellLayoutClasses,
|
||||||
|
writeShellSessionExpiryEnabled,
|
||||||
|
} from "../../../../common/shell";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import { Toaster } from "../ui/toaster";
|
import { Toaster } from "../ui/toaster";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{
|
||||||
|
labelKey: "ui.dev.nav.overview",
|
||||||
|
labelFallback: "Overview",
|
||||||
|
to: "/",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.nav.clients",
|
labelKey: "ui.dev.nav.clients",
|
||||||
labelFallback: "Clients",
|
labelFallback: "Clients",
|
||||||
@@ -52,15 +68,11 @@ function AppLayout() {
|
|||||||
const isRenewInFlightRef = useRef(false);
|
const isRenewInFlightRef = useRef(false);
|
||||||
const lastRenewAttemptAtRef = useRef(0);
|
const lastRenewAttemptAtRef = useRef(0);
|
||||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
|
||||||
return stored === "dark" ? "dark" : "light";
|
|
||||||
});
|
|
||||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
||||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
readShellSessionExpiryEnabled,
|
||||||
return stored !== "false";
|
);
|
||||||
});
|
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
@@ -82,14 +94,7 @@ function AppLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
applyShellTheme(theme);
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
if (theme === "light") {
|
|
||||||
root.classList.add("light");
|
|
||||||
} else {
|
|
||||||
root.classList.add("dark");
|
|
||||||
}
|
|
||||||
window.localStorage.setItem("admin_theme", theme);
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -265,84 +270,41 @@ function AppLayout() {
|
|||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileName =
|
const profileSummary = buildShellProfileSummary({
|
||||||
profile?.name?.trim() ||
|
profileName:
|
||||||
auth.user?.profile?.name?.toString().trim() ||
|
profile?.name ||
|
||||||
auth.user?.profile?.preferred_username?.toString().trim() ||
|
auth.user?.profile?.name?.toString() ||
|
||||||
auth.user?.profile?.nickname?.toString().trim() ||
|
auth.user?.profile?.preferred_username?.toString() ||
|
||||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
auth.user?.profile?.nickname?.toString(),
|
||||||
const profileEmail =
|
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
|
||||||
profile?.email?.trim() ||
|
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
|
||||||
auth.user?.profile?.email?.toString().trim() ||
|
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
|
||||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
});
|
||||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
|
||||||
const currentRole = resolveProfileRole(
|
const currentRole = resolveProfileRole(
|
||||||
auth.user?.profile as Record<string, unknown> | undefined,
|
auth.user?.profile as Record<string, unknown> | undefined,
|
||||||
);
|
);
|
||||||
const displayRoleKey = profile?.role || currentRole;
|
const displayRoleKey = profile?.role || currentRole;
|
||||||
|
const sessionStatus = buildShellSessionStatus({
|
||||||
const expiresAtSec = auth.user?.expires_at;
|
expiresAtSec: auth.user?.expires_at,
|
||||||
const remainingMs =
|
nowMs,
|
||||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
t,
|
||||||
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 handleSessionExpiryToggle = () => {
|
const handleSessionExpiryToggle = () => {
|
||||||
setIsSessionExpiryEnabled((prev) => {
|
setIsSessionExpiryEnabled((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
writeShellSessionExpiryEnabled(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<div className={shellLayoutClasses.root}>
|
||||||
<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">
|
<aside className={shellLayoutClasses.aside}>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
<div className={shellLayoutClasses.brandSection}>
|
||||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
<div className={shellLayoutClasses.brandWrap}>
|
||||||
<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.brandIcon}>
|
||||||
<ShieldHalf size={20} />
|
<ShieldHalf size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -354,28 +316,29 @@ function AppLayout() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<BadgeCheck size={14} />
|
||||||
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
<nav className={shellLayoutClasses.navWrap}>
|
||||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
<div className={shellLayoutClasses.navMeta}>
|
||||||
<span className="rounded-full border border-border px-3 py-1">
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
{t("ui.dev.env_badge", "Env: dev")}
|
{t("ui.dev.env_badge", "Env: dev")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className={shellLayoutClasses.navList}>
|
||||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
|
end={to === "/"}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
shellLayoutClasses.navItemBase,
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
? shellLayoutClasses.navItemActive
|
||||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
: shellLayoutClasses.navItemIdle,
|
||||||
].join(" ")
|
].join(" ")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -392,13 +355,13 @@ function AppLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
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} />
|
<LogOut size={18} />
|
||||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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("msg.dev.sidebar.notice", "Developer Console")}</p>
|
||||||
<p>
|
<p>
|
||||||
{t(
|
{t(
|
||||||
@@ -410,10 +373,10 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="relative">
|
<div className={shellLayoutClasses.content}>
|
||||||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
<header className={shellLayoutClasses.header}>
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
<div className={shellLayoutClasses.headerInner}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
{t("ui.dev.header.plane", "Dev Plane")}
|
{t("ui.dev.header.plane", "Dev Plane")}
|
||||||
</p>
|
</p>
|
||||||
@@ -421,12 +384,12 @@ function AppLayout() {
|
|||||||
{t("ui.dev.header.subtitle", "Manage your applications")}
|
{t("ui.dev.header.subtitle", "Manage your applications")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className={shellLayoutClasses.headerActions}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleTheme}
|
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")}
|
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
|
||||||
>
|
>
|
||||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
@@ -437,11 +400,11 @@ function AppLayout() {
|
|||||||
{isSessionExpiryEnabled ? (
|
{isSessionExpiryEnabled ? (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
shellLayoutClasses.sessionBadge,
|
||||||
sessionToneClass,
|
sessionStatus.toneClass,
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{sessionText}
|
{sessionStatus.text}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative" ref={profileMenuRef}>
|
<div className="relative" ref={profileMenuRef}>
|
||||||
@@ -456,15 +419,15 @@ function AppLayout() {
|
|||||||
"Open account menu",
|
"Open account menu",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
|
<div className={shellLayoutClasses.profileInitial}>
|
||||||
{profileInitial}
|
{profileSummary.initial}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden min-w-0 text-left md:block">
|
<div className="hidden min-w-0 text-left md:block">
|
||||||
<p className="truncate text-xs font-medium text-foreground">
|
<p className="truncate text-xs font-medium text-foreground">
|
||||||
{profileName}
|
{profileSummary.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] text-muted-foreground">
|
<p className="truncate text-[11px] text-muted-foreground">
|
||||||
{profileEmail}
|
{profileSummary.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -473,20 +436,17 @@ function AppLayout() {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{isProfileMenuOpen ? (
|
{isProfileMenuOpen ? (
|
||||||
<div
|
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||||
role="menu"
|
|
||||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
|
||||||
>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{t("ui.dev.profile.menu_title", "Account")}
|
{t("ui.dev.profile.menu_title", "Account")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
<div className={shellLayoutClasses.profileCard}>
|
||||||
<div>
|
<div>
|
||||||
<p className="truncate text-sm font-semibold text-foreground">
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
{profileName}
|
{profileSummary.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{profileEmail}
|
{profileSummary.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center pt-1">
|
<div className="flex items-center pt-1">
|
||||||
@@ -499,7 +459,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</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 className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
@@ -507,7 +467,7 @@ function AppLayout() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isSessionExpiryEnabled
|
{isSessionExpiryEnabled
|
||||||
? sessionText
|
? sessionStatus.text
|
||||||
: t(
|
: t(
|
||||||
"ui.dev.session.disabled",
|
"ui.dev.session.disabled",
|
||||||
"Session expiry disabled",
|
"Session expiry disabled",
|
||||||
@@ -563,7 +523,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
<main className={shellLayoutClasses.main}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,21 @@
|
|||||||
import { type VariantProps, cva } from "class-variance-authority";
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import {
|
||||||
|
type CommonBadgeVariant,
|
||||||
|
getCommonBadgeClasses,
|
||||||
|
} from "../../../../common/ui/badge";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
"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",
|
variant?: CommonBadgeVariant;
|
||||||
{
|
}
|
||||||
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> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
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 { Slot } from "@radix-ui/react-slot";
|
||||||
import { type VariantProps, cva } from "class-variance-authority";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
type CommonButtonSize,
|
||||||
|
type CommonButtonVariant,
|
||||||
|
getCommonButtonClasses,
|
||||||
|
} from "../../../../common/ui/button";
|
||||||
import { cn } from "../../lib/utils";
|
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
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
VariantProps<typeof buttonVariants> {
|
variant?: CommonButtonVariant;
|
||||||
|
size?: CommonButtonSize;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button };
|
||||||
|
|||||||
@@ -1,65 +1,51 @@
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import {
|
||||||
|
commonCardClass,
|
||||||
|
commonCardContentClass,
|
||||||
|
commonCardDescriptionClass,
|
||||||
|
commonCardFooterClass,
|
||||||
|
commonCardHeaderClass,
|
||||||
|
commonCardTitleClass,
|
||||||
|
} from "../../../../common/ui/card";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({
|
function CardHeader({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({
|
function CardTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
return (
|
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||||
<h3
|
|
||||||
className={cn("text-lg font-semibold leading-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({
|
function CardDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
return (
|
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({
|
function CardContent({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({
|
function CardFooter({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { commonInputClass } from "../../../../common/ui/input";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(commonInputClass, className)}
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { BookOpenText, Filter, Plus, Search, X } from "lucide-react";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowUpDown,
|
||||||
|
BookOpenText,
|
||||||
|
Filter,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
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 { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -37,6 +52,7 @@ import {
|
|||||||
fetchDeveloperRequestStatus,
|
fetchDeveloperRequestStatus,
|
||||||
fetchMyTenants,
|
fetchMyTenants,
|
||||||
requestDeveloperAccess,
|
requestDeveloperAccess,
|
||||||
|
type ClientSummary,
|
||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
@@ -44,6 +60,8 @@ import { cn } from "../../lib/utils";
|
|||||||
import { fetchMe } from "../auth/authApi";
|
import { fetchMe } from "../auth/authApi";
|
||||||
import { ClientLogo } from "./components/ClientLogo";
|
import { ClientLogo } from "./components/ClientLogo";
|
||||||
|
|
||||||
|
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
|
||||||
|
|
||||||
function ClientsPage() {
|
function ClientsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -104,19 +122,48 @@ function ClientsPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
|
||||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||||
|
const [sortConfig, setSortConfig] =
|
||||||
|
useState<SortConfig<ClientSortKey> | null>(null);
|
||||||
|
|
||||||
const clients = data?.items || [];
|
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 filteredClients = useMemo(() => {
|
||||||
const matchesSearch =
|
const nextClients = clients.filter((client) => {
|
||||||
!searchQuery ||
|
const matchesSearch =
|
||||||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
!searchQuery ||
|
||||||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
|
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
const matchesType = typeFilter === "all" || client.type === typeFilter;
|
client.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
const matchesStatus =
|
const matchesType = typeFilter === "all" || client.type === typeFilter;
|
||||||
statusFilter === "all" || client.status === statusFilter;
|
const matchesStatus =
|
||||||
return matchesSearch && matchesType && 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 totalClients = statsData?.total_clients ?? clients.length;
|
||||||
const activeSessions = statsData?.active_sessions ?? 0;
|
const activeSessions = statsData?.active_sessions ?? 0;
|
||||||
@@ -179,6 +226,22 @@ function ClientsPage() {
|
|||||||
|
|
||||||
const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
|
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) {
|
if (auth.isLoading || !hasAccessToken || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
@@ -389,18 +452,50 @@ function ClientsPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead
|
||||||
{t("ui.dev.clients.table.application", "애플리케이션")}
|
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>
|
||||||
<TableHead>
|
<TableHead
|
||||||
{t("ui.dev.clients.table.client_id", "Client ID")}
|
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>
|
||||||
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
|
<TableHead
|
||||||
<TableHead>
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
{t("ui.dev.clients.table.status", "상태")}
|
onClick={() => requestSort("type")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.dev.clients.table.type", "유형")}
|
||||||
|
{getSortIcon("type")}
|
||||||
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead
|
||||||
{t("ui.dev.clients.table.created_at", "생성일")}
|
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>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
{t("ui.dev.clients.table.actions", "액션")}
|
{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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -24,37 +26,7 @@
|
|||||||
--input: 215 25% 24%;
|
--input: 215 25% 24%;
|
||||||
--ring: 209 79% 52%;
|
--ring: 209 79% 52%;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
}
|
--app-background-image: radial-gradient(
|
||||||
|
|
||||||
.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(
|
|
||||||
circle at 10% 18%,
|
circle at 10% 18%,
|
||||||
rgba(54, 211, 153, 0.16),
|
rgba(54, 211, 153, 0.16),
|
||||||
transparent 28%
|
transparent 28%
|
||||||
@@ -70,14 +42,4 @@
|
|||||||
transparent 30%
|
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 { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
import type { AuthProviderProps } from "react-oidc-context";
|
import type { AuthProviderProps } from "react-oidc-context";
|
||||||
import {
|
import {
|
||||||
buildDevFrontAuthRedirectUris,
|
buildCommonOidcRuntimeConfig,
|
||||||
resolveDevFrontPublicOrigin,
|
buildCommonUserManagerSettings,
|
||||||
} from "./authConfig";
|
} from "../../../common/core/auth";
|
||||||
|
import { resolveDevFrontPublicOrigin } from "./authConfig";
|
||||||
|
|
||||||
const devFrontPublicOrigin = resolveDevFrontPublicOrigin(
|
const devFrontPublicOrigin = resolveDevFrontPublicOrigin(
|
||||||
import.meta.env.VITE_DEVFRONT_PUBLIC_URL,
|
import.meta.env.VITE_DEVFRONT_PUBLIC_URL,
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const devFrontRedirectUris =
|
|
||||||
buildDevFrontAuthRedirectUris(devFrontPublicOrigin);
|
|
||||||
|
|
||||||
export const oidcConfig: AuthProviderProps = {
|
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
||||||
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
|
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
|
||||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
|
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
|
||||||
redirect_uri: devFrontRedirectUris.redirectUri,
|
origin: devFrontPublicOrigin,
|
||||||
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,
|
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
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;
|
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 = {
|
export type DevAuditLog = {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -214,6 +235,22 @@ export async function fetchDevStats() {
|
|||||||
return data;
|
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(
|
export async function fetchTenants(
|
||||||
limit = 1000,
|
limit = 1000,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
|||||||
@@ -1,149 +1,16 @@
|
|||||||
const LOCALE_STORAGE_KEY = "locale";
|
import { createTomlTranslator } from "../../../common/core/i18n";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import enRaw from "../locales/en.toml?raw";
|
import enRaw from "../locales/en.toml?raw";
|
||||||
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import koRaw from "../locales/ko.toml?raw";
|
import koRaw from "../locales/ko.toml?raw";
|
||||||
|
|
||||||
const translations: Record<Locale, TomlObject> = {
|
export const t = createTomlTranslator({
|
||||||
ko: parseToml(koRaw),
|
ko: [commonKoRaw, koRaw],
|
||||||
en: parseToml(enRaw),
|
en: [commonEnRaw, 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,76 +1,6 @@
|
|||||||
export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
|
export {
|
||||||
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
|
||||||
|
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
|
||||||
type SlidingSessionRenewDecisionParams = {
|
shouldAttemptSlidingSessionRenew,
|
||||||
expiresAtSec?: number | null;
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
nowMs: number;
|
} from "../../../common/core/session";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { mergeClassNames } from "../../../common/core/utils";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
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]
|
[msg.admin.users.list.registry]
|
||||||
count = "Count"
|
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]
|
[msg.dev]
|
||||||
logout_confirm = "Are you sure you want to log out?"
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
|
|
||||||
@@ -509,6 +500,7 @@ openid = "Openid"
|
|||||||
profile = "Profile"
|
profile = "Profile"
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = "View connected application composition and authentication operations metrics in one place."
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = "Body"
|
body = "Body"
|
||||||
@@ -516,6 +508,29 @@ title_emphasis = "Title Emphasis"
|
|||||||
title_prefix = "Title Prefix"
|
title_prefix = "Title Prefix"
|
||||||
title_suffix = "Title Suffix"
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = "Consent Audit"
|
consent_audit = "Consent Audit"
|
||||||
dev_scope = "Dev Scope"
|
dev_scope = "Dev Scope"
|
||||||
@@ -1244,75 +1259,6 @@ name = "Name"
|
|||||||
role = "Role"
|
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]
|
[test]
|
||||||
key = "Test"
|
key = "Test"
|
||||||
|
|
||||||
@@ -1330,6 +1276,7 @@ audit_logs = "Audit Logs"
|
|||||||
clients = "Connected Application"
|
clients = "Connected Application"
|
||||||
developer_request = "Developer Access Request"
|
developer_request = "Developer Access Request"
|
||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
|
overview = "Overview"
|
||||||
|
|
||||||
[ui.dev.audit]
|
[ui.dev.audit]
|
||||||
load_more = "Load more"
|
load_more = "Load more"
|
||||||
@@ -1707,12 +1654,32 @@ private_headless = "Server side App (Headless Login)"
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
title = "Dashboard"
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = "Consent guard ready"
|
consent_guard = "Consent guard ready"
|
||||||
|
oidc = "OIDC operations"
|
||||||
policy_toggle = "Policy toggle enabled"
|
policy_toggle = "Policy toggle enabled"
|
||||||
|
registry = "RP registry"
|
||||||
rp_synced = "RP registry synced"
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = "Ship the RP controls"
|
subtitle = "Ship the RP controls"
|
||||||
title = "Next actions"
|
title = "Next actions"
|
||||||
@@ -1730,11 +1697,25 @@ rp_requests = "Rp Requests"
|
|||||||
consent = "Consent grants"
|
consent = "Consent grants"
|
||||||
rp_status = "RP status"
|
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]
|
[ui.dev.dashboard.stack]
|
||||||
notes = "Setup notes"
|
notes = "Setup notes"
|
||||||
subtitle = "Devfront baseline"
|
subtitle = "Devfront baseline"
|
||||||
title = "Stack readiness"
|
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]
|
[ui.dev.header]
|
||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|||||||
@@ -300,15 +300,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다."
|
|||||||
[msg.admin.users.list.registry]
|
[msg.admin.users.list.registry]
|
||||||
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
|
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
|
||||||
|
|
||||||
[msg.common]
|
|
||||||
error = "오류가 발생했습니다."
|
|
||||||
loading = "로딩 중..."
|
|
||||||
no_description = "설명이 없습니다."
|
|
||||||
parsing = "데이터 파싱 중..."
|
|
||||||
requesting = "요청 중..."
|
|
||||||
saving = "저장 중..."
|
|
||||||
unknown_error = "알 수 없는 오류"
|
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = "로그아웃 하시겠습니까?"
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
|
|
||||||
@@ -509,6 +500,7 @@ openid = "OIDC 인증 필수 스코프"
|
|||||||
profile = "기본 프로필 정보 접근"
|
profile = "기본 프로필 정보 접근"
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
|
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
|
||||||
@@ -516,6 +508,29 @@ title_emphasis = " 하나의 화면"
|
|||||||
title_prefix = "RP 등록 현황과 Consent 상태를"
|
title_prefix = "RP 등록 현황과 Consent 상태를"
|
||||||
title_suffix = "에서 관리합니다."
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = "Consent 회수는 감사 로그와 연계"
|
consent_audit = "Consent 회수는 감사 로그와 연계"
|
||||||
dev_scope = "RP 정책은 dev scope에서만 적용"
|
dev_scope = "RP 정책은 dev scope에서만 적용"
|
||||||
@@ -1244,75 +1259,6 @@ name = "이름"
|
|||||||
role = "역할"
|
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]
|
[test]
|
||||||
key = "테스트"
|
key = "테스트"
|
||||||
|
|
||||||
@@ -1330,6 +1276,7 @@ audit_logs = "감사 로그"
|
|||||||
clients = "연동 앱"
|
clients = "연동 앱"
|
||||||
developer_request = "개발자 권한 신청"
|
developer_request = "개발자 권한 신청"
|
||||||
logout = "로그아웃"
|
logout = "로그아웃"
|
||||||
|
overview = "개요"
|
||||||
|
|
||||||
[ui.dev.audit]
|
[ui.dev.audit]
|
||||||
load_more = "더 보기"
|
load_more = "더 보기"
|
||||||
@@ -1706,12 +1653,32 @@ private_headless = "Server side App (Headless Login)"
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
title = "대시보드"
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = "Consent guard ready"
|
consent_guard = "Consent guard ready"
|
||||||
|
oidc = "OIDC 운영"
|
||||||
policy_toggle = "Policy toggle enabled"
|
policy_toggle = "Policy toggle enabled"
|
||||||
|
registry = "RP registry"
|
||||||
rp_synced = "RP registry synced"
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = "Ship the RP controls"
|
subtitle = "Ship the RP controls"
|
||||||
title = "Next actions"
|
title = "Next actions"
|
||||||
@@ -1729,11 +1696,25 @@ rp_requests = "RP 요청 추이"
|
|||||||
consent = "Consent grants"
|
consent = "Consent grants"
|
||||||
rp_status = "RP status"
|
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]
|
[ui.dev.dashboard.stack]
|
||||||
notes = "Setup notes"
|
notes = "Setup notes"
|
||||||
subtitle = "Devfront baseline"
|
subtitle = "Devfront baseline"
|
||||||
title = "Stack readiness"
|
title = "Stack readiness"
|
||||||
|
|
||||||
|
[ui.dev.dashboard.summary]
|
||||||
|
active_clients = "활성 RP 수"
|
||||||
|
active_sessions = "활성 세션 수"
|
||||||
|
auth_failures_24h = "24시간 인증 실패 수"
|
||||||
|
total_clients = "총 RP 수"
|
||||||
|
|
||||||
[ui.dev.header]
|
[ui.dev.header]
|
||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|||||||
@@ -314,15 +314,6 @@ no_custom = ""
|
|||||||
[msg.admin.users.list.registry]
|
[msg.admin.users.list.registry]
|
||||||
count = ""
|
count = ""
|
||||||
|
|
||||||
[msg.common]
|
|
||||||
error = ""
|
|
||||||
loading = ""
|
|
||||||
no_description = ""
|
|
||||||
parsing = ""
|
|
||||||
requesting = ""
|
|
||||||
saving = ""
|
|
||||||
unknown_error = ""
|
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = ""
|
logout_confirm = ""
|
||||||
|
|
||||||
@@ -547,6 +538,7 @@ openid = ""
|
|||||||
profile = ""
|
profile = ""
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = ""
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = ""
|
body = ""
|
||||||
@@ -554,6 +546,29 @@ title_emphasis = ""
|
|||||||
title_prefix = ""
|
title_prefix = ""
|
||||||
title_suffix = ""
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = ""
|
consent_audit = ""
|
||||||
dev_scope = ""
|
dev_scope = ""
|
||||||
@@ -1297,75 +1312,6 @@ name = ""
|
|||||||
role = ""
|
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]
|
[test]
|
||||||
key = ""
|
key = ""
|
||||||
|
|
||||||
@@ -1381,8 +1327,9 @@ scope_badge = ""
|
|||||||
[ui.dev.nav]
|
[ui.dev.nav]
|
||||||
audit_logs = ""
|
audit_logs = ""
|
||||||
clients = ""
|
clients = ""
|
||||||
logout = ""
|
|
||||||
developer_request = ""
|
developer_request = ""
|
||||||
|
logout = ""
|
||||||
|
overview = ""
|
||||||
|
|
||||||
[ui.dev.welcome]
|
[ui.dev.welcome]
|
||||||
btn_request = ""
|
btn_request = ""
|
||||||
@@ -1763,12 +1710,32 @@ private_headless = ""
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = ""
|
ready_badge = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = ""
|
consent_guard = ""
|
||||||
|
oidc = ""
|
||||||
policy_toggle = ""
|
policy_toggle = ""
|
||||||
|
registry = ""
|
||||||
rp_synced = ""
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -1786,11 +1753,25 @@ rp_requests = ""
|
|||||||
consent = ""
|
consent = ""
|
||||||
rp_status = ""
|
rp_status = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.quick_links]
|
||||||
|
create_button = ""
|
||||||
|
new_client = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.recent]
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.dev.dashboard.stack]
|
[ui.dev.dashboard.stack]
|
||||||
notes = ""
|
notes = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.summary]
|
||||||
|
active_clients = ""
|
||||||
|
active_sessions = ""
|
||||||
|
auth_failures_24h = ""
|
||||||
|
total_clients = ""
|
||||||
|
|
||||||
[ui.dev.header]
|
[ui.dev.header]
|
||||||
plane = ""
|
plane = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme";
|
|||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
|
"../common/**/*.{ts,tsx,css}",
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
center: true,
|
center: true,
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ services:
|
|||||||
- "${ADMINFRONT_PORT:-5173}:5173"
|
- "${ADMINFRONT_PORT:-5173}:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ./adminfront:/app
|
- ./adminfront:/app
|
||||||
|
- ./common:/common
|
||||||
- ./locales:/locales
|
- ./locales:/locales
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
networks:
|
networks:
|
||||||
@@ -82,6 +83,7 @@ services:
|
|||||||
- "${DEVFRONT_PORT:-5174}:5173"
|
- "${DEVFRONT_PORT:-5174}:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ./devfront:/app
|
- ./devfront:/app
|
||||||
|
- ./common:/common
|
||||||
- ./locales:/locales
|
- ./locales:/locales
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
networks:
|
networks:
|
||||||
@@ -102,6 +104,7 @@ services:
|
|||||||
- "${ORGFRONT_PORT:-5175}:5175"
|
- "${ORGFRONT_PORT:-5175}:5175"
|
||||||
volumes:
|
volumes:
|
||||||
- ./orgfront:/app
|
- ./orgfront:/app
|
||||||
|
- ./common:/common
|
||||||
- ./locales:/locales
|
- ./locales:/locales
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
10
docs/i18n.md
10
docs/i18n.md
@@ -119,14 +119,18 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
|
|||||||
./scripts/sync_userfront_locales.sh
|
./scripts/sync_userfront_locales.sh
|
||||||
```
|
```
|
||||||
* 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다.
|
* 이 단계가 누락되면 루트 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)**:
|
3. **CI 검증 (Verification)**:
|
||||||
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
|
* **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`)**
|
* **Level 2: 코드 사용성 검사 (`code` vs `template`)**
|
||||||
* 전체 프론트엔드 소스코드(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)를 스캔하여 번역 함수(`t('key')`, `'key'.tr()`)에 사용된 키를 추출합니다.
|
* 전체 프론트엔드 소스코드(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)를 스캔하여 번역 함수(`t('key')`, `'key'.tr()`)에 사용된 키를 추출합니다.
|
||||||
* **Missing Key**: 코드에는 있는데 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다.
|
* **Missing Key**: 코드에는 있는데 해당 레이어의 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다.
|
||||||
* **Unused Key**: `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다.
|
* **Unused Key**: 각 `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다.
|
||||||
|
|
||||||
#### 5.2.3 React (Admin/Dev) 구현 가이드
|
#### 5.2.3 React (Admin/Dev) 구현 가이드
|
||||||
* **패키지 설치**:
|
* **패키지 설치**:
|
||||||
|
|||||||
@@ -553,6 +553,7 @@ openid = "Openid"
|
|||||||
profile = "Profile"
|
profile = "Profile"
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = "Review RP composition and authentication operations in one place."
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = "Monitor RP readiness, consent activity, and operational status for the current developer workspace."
|
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_prefix = "Title Prefix"
|
||||||
title_suffix = "Title Suffix"
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = "Consent Audit"
|
consent_audit = "Consent Audit"
|
||||||
dev_scope = "Dev Scope"
|
dev_scope = "Dev Scope"
|
||||||
@@ -1141,6 +1157,11 @@ parent_organizations = "Parent Organizations"
|
|||||||
parent_unresolved = "Parent needs review"
|
parent_unresolved = "Parent needs review"
|
||||||
slug_exists = "slug conflict"
|
slug_exists = "slug conflict"
|
||||||
title = "Confirm CSV import"
|
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]
|
[ui.admin.tenants.admins]
|
||||||
add_button = "Add Button"
|
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."
|
parent_help = "Select a parent tenant if this is a subsidiary or sub-organization."
|
||||||
|
|
||||||
[ui.admin.tenants.parent]
|
[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"
|
local_search_placeholder = "Search tenant name or slug"
|
||||||
pick_tenant = "Pick tenant"
|
pick_tenant = "Pick tenant"
|
||||||
|
|
||||||
@@ -2177,12 +2200,32 @@ private = "Server side App"
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
title = "Dashboard"
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = "Consent guard ready"
|
consent_guard = "Consent guard ready"
|
||||||
|
oidc = "OIDC operations"
|
||||||
policy_toggle = "Policy toggle enabled"
|
policy_toggle = "Policy toggle enabled"
|
||||||
|
registry = "RP registry"
|
||||||
rp_synced = "RP registry synced"
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = "Ship the RP controls"
|
subtitle = "Ship the RP controls"
|
||||||
title = "Next actions"
|
title = "Next actions"
|
||||||
@@ -2200,11 +2243,25 @@ rp_requests = "Rp Requests"
|
|||||||
consent = "Consent grants"
|
consent = "Consent grants"
|
||||||
rp_status = "RP status"
|
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]
|
[ui.dev.dashboard.stack]
|
||||||
notes = "Setup notes"
|
notes = "Setup notes"
|
||||||
subtitle = "Devfront baseline"
|
subtitle = "Devfront baseline"
|
||||||
title = "Stack readiness"
|
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]
|
[ui.dev.header]
|
||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|||||||
@@ -384,6 +384,11 @@ parent_organizations = "상위 조직"
|
|||||||
parent_unresolved = "부모 확인 필요"
|
parent_unresolved = "부모 확인 필요"
|
||||||
slug_exists = "slug 충돌"
|
slug_exists = "slug 충돌"
|
||||||
title = "CSV 가져오기 확인"
|
title = "CSV 가져오기 확인"
|
||||||
|
csv_parents = "가져오기 CSV"
|
||||||
|
parent = "상위"
|
||||||
|
parent_companies = "회사"
|
||||||
|
parent_company_groups = "그룹사"
|
||||||
|
parent_organizations = "조직"
|
||||||
|
|
||||||
[ui.common.badge]
|
[ui.common.badge]
|
||||||
admin_only = "Admin only"
|
admin_only = "Admin only"
|
||||||
@@ -1039,6 +1044,7 @@ openid = "OIDC 인증 필수 스코프"
|
|||||||
profile = "기본 프로필 정보 접근"
|
profile = "기본 프로필 정보 접근"
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
|
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
|
||||||
@@ -1046,6 +1052,21 @@ title_emphasis = " 하나의 화면"
|
|||||||
title_prefix = "RP 등록 현황과 Consent 상태를"
|
title_prefix = "RP 등록 현황과 Consent 상태를"
|
||||||
title_suffix = "에서 관리합니다."
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = "Consent 회수는 감사 로그와 연계"
|
consent_audit = "Consent 회수는 감사 로그와 연계"
|
||||||
dev_scope = "RP 정책은 dev scope에서만 적용"
|
dev_scope = "RP 정책은 dev scope에서만 적용"
|
||||||
@@ -1778,6 +1799,8 @@ parent = "상위 테넌트 (선택)"
|
|||||||
parent_help = "가족사 테넌트나 하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요."
|
parent_help = "가족사 테넌트나 하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요."
|
||||||
|
|
||||||
[ui.admin.tenants.parent]
|
[ui.admin.tenants.parent]
|
||||||
|
company_only = "회사/그룹사만 표시"
|
||||||
|
search_placeholder = "이름 또는 slug 검색"
|
||||||
local_search_placeholder = "테넌트 이름 또는 슬러그 검색"
|
local_search_placeholder = "테넌트 이름 또는 슬러그 검색"
|
||||||
pick_tenant = "테넌트 선택"
|
pick_tenant = "테넌트 선택"
|
||||||
|
|
||||||
@@ -2639,12 +2662,32 @@ private = "Server side App"
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = "devfront ready"
|
ready_badge = "devfront ready"
|
||||||
|
title = "대시보드"
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = "Consent guard ready"
|
consent_guard = "Consent guard ready"
|
||||||
|
oidc = "OIDC 운영"
|
||||||
policy_toggle = "Policy toggle enabled"
|
policy_toggle = "Policy toggle enabled"
|
||||||
|
registry = "RP registry"
|
||||||
rp_synced = "RP registry synced"
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = "Ship the RP controls"
|
subtitle = "Ship the RP controls"
|
||||||
title = "Next actions"
|
title = "Next actions"
|
||||||
@@ -2662,11 +2705,25 @@ rp_requests = "RP 요청 추이"
|
|||||||
consent = "Consent grants"
|
consent = "Consent grants"
|
||||||
rp_status = "RP status"
|
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]
|
[ui.dev.dashboard.stack]
|
||||||
notes = "Setup notes"
|
notes = "Setup notes"
|
||||||
subtitle = "Devfront baseline"
|
subtitle = "Devfront baseline"
|
||||||
title = "Stack readiness"
|
title = "Stack readiness"
|
||||||
|
|
||||||
|
[ui.dev.dashboard.summary]
|
||||||
|
active_clients = "활성 RP 수"
|
||||||
|
active_sessions = "활성 세션 수"
|
||||||
|
auth_failures_24h = "24시간 인증 실패 수"
|
||||||
|
total_clients = "총 RP 수"
|
||||||
|
|
||||||
[ui.dev.header]
|
[ui.dev.header]
|
||||||
plane = "Dev Plane"
|
plane = "Dev Plane"
|
||||||
subtitle = "Manage your applications"
|
subtitle = "Manage your applications"
|
||||||
|
|||||||
@@ -248,6 +248,11 @@ parent_organizations = ""
|
|||||||
parent_unresolved = ""
|
parent_unresolved = ""
|
||||||
slug_exists = ""
|
slug_exists = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
csv_parents = ""
|
||||||
|
parent = ""
|
||||||
|
parent_companies = ""
|
||||||
|
parent_company_groups = ""
|
||||||
|
parent_organizations = ""
|
||||||
|
|
||||||
[ui.common.badge]
|
[ui.common.badge]
|
||||||
admin_only = ""
|
admin_only = ""
|
||||||
@@ -903,6 +908,7 @@ openid = ""
|
|||||||
profile = ""
|
profile = ""
|
||||||
|
|
||||||
[msg.dev.dashboard]
|
[msg.dev.dashboard]
|
||||||
|
description = ""
|
||||||
|
|
||||||
[msg.dev.dashboard.hero]
|
[msg.dev.dashboard.hero]
|
||||||
body = ""
|
body = ""
|
||||||
@@ -910,6 +916,21 @@ title_emphasis = ""
|
|||||||
title_prefix = ""
|
title_prefix = ""
|
||||||
title_suffix = ""
|
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]
|
[msg.dev.dashboard.notice]
|
||||||
consent_audit = ""
|
consent_audit = ""
|
||||||
dev_scope = ""
|
dev_scope = ""
|
||||||
@@ -1643,6 +1664,8 @@ parent = ""
|
|||||||
parent_help = ""
|
parent_help = ""
|
||||||
|
|
||||||
[ui.admin.tenants.parent]
|
[ui.admin.tenants.parent]
|
||||||
|
company_only = ""
|
||||||
|
search_placeholder = ""
|
||||||
local_search_placeholder = ""
|
local_search_placeholder = ""
|
||||||
pick_tenant = ""
|
pick_tenant = ""
|
||||||
|
|
||||||
@@ -2518,12 +2541,32 @@ private = ""
|
|||||||
|
|
||||||
[ui.dev.dashboard]
|
[ui.dev.dashboard]
|
||||||
ready_badge = ""
|
ready_badge = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.dev.dashboard.badge]
|
[ui.dev.dashboard.badge]
|
||||||
consent_guard = ""
|
consent_guard = ""
|
||||||
|
oidc = ""
|
||||||
policy_toggle = ""
|
policy_toggle = ""
|
||||||
|
registry = ""
|
||||||
rp_synced = ""
|
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]
|
[ui.dev.dashboard.next]
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -2541,11 +2584,25 @@ rp_requests = ""
|
|||||||
consent = ""
|
consent = ""
|
||||||
rp_status = ""
|
rp_status = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.quick_links]
|
||||||
|
create_button = ""
|
||||||
|
new_client = ""
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.recent]
|
||||||
|
title = ""
|
||||||
|
|
||||||
[ui.dev.dashboard.stack]
|
[ui.dev.dashboard.stack]
|
||||||
notes = ""
|
notes = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.dev.dashboard.summary]
|
||||||
|
active_clients = ""
|
||||||
|
active_sessions = ""
|
||||||
|
auth_failures_24h = ""
|
||||||
|
total_clients = ""
|
||||||
|
|
||||||
[ui.dev.header]
|
[ui.dev.header]
|
||||||
plane = ""
|
plane = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: queryClientDefaultOptions,
|
||||||
queries: {
|
|
||||||
staleTime: 30_000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ import {
|
|||||||
shouldAttemptSlidingSessionRenew,
|
shouldAttemptSlidingSessionRenew,
|
||||||
shouldAttemptUnlimitedSessionRenew,
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
} from "../../lib/sessionSliding";
|
} from "../../lib/sessionSliding";
|
||||||
|
import {
|
||||||
|
applyShellTheme,
|
||||||
|
buildShellProfileSummary,
|
||||||
|
buildShellSessionStatus,
|
||||||
|
readShellSessionExpiryEnabled,
|
||||||
|
readShellTheme,
|
||||||
|
shellLayoutClasses,
|
||||||
|
writeShellSessionExpiryEnabled,
|
||||||
|
} from "../../../../common/shell";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import { Toaster } from "../ui/toaster";
|
import { Toaster } from "../ui/toaster";
|
||||||
|
|
||||||
@@ -45,15 +54,11 @@ function AppLayout() {
|
|||||||
const isRenewInFlightRef = useRef(false);
|
const isRenewInFlightRef = useRef(false);
|
||||||
const lastRenewAttemptAtRef = useRef(0);
|
const lastRenewAttemptAtRef = useRef(0);
|
||||||
const lastVisitedRouteRef = useRef<string | null>(null);
|
const lastVisitedRouteRef = useRef<string | null>(null);
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||||
const stored = window.localStorage.getItem("admin_theme");
|
|
||||||
return stored === "dark" ? "dark" : "light";
|
|
||||||
});
|
|
||||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
||||||
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
|
readShellSessionExpiryEnabled,
|
||||||
return stored !== "false";
|
);
|
||||||
});
|
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
@@ -71,14 +76,7 @@ function AppLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
applyShellTheme(theme);
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
if (theme === "light") {
|
|
||||||
root.classList.add("light");
|
|
||||||
} else {
|
|
||||||
root.classList.add("dark");
|
|
||||||
}
|
|
||||||
window.localStorage.setItem("admin_theme", theme);
|
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -254,17 +252,16 @@ function AppLayout() {
|
|||||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileName =
|
const profileSummary = buildShellProfileSummary({
|
||||||
profile?.name?.trim() ||
|
profileName:
|
||||||
auth.user?.profile?.name?.toString().trim() ||
|
profile?.name ||
|
||||||
auth.user?.profile?.preferred_username?.toString().trim() ||
|
auth.user?.profile?.name?.toString() ||
|
||||||
auth.user?.profile?.nickname?.toString().trim() ||
|
auth.user?.profile?.preferred_username?.toString() ||
|
||||||
t("ui.dev.profile.unknown_name", "Unknown User");
|
auth.user?.profile?.nickname?.toString(),
|
||||||
const profileEmail =
|
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
|
||||||
profile?.email?.trim() ||
|
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
|
||||||
auth.user?.profile?.email?.toString().trim() ||
|
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
|
||||||
t("ui.dev.profile.unknown_email", "unknown@example.com");
|
});
|
||||||
const profileInitial = profileName.charAt(0).toUpperCase();
|
|
||||||
const currentRole = resolveProfileRole(
|
const currentRole = resolveProfileRole(
|
||||||
auth.user?.profile as Record<string, unknown> | undefined,
|
auth.user?.profile as Record<string, unknown> | undefined,
|
||||||
);
|
);
|
||||||
@@ -275,68 +272,27 @@ function AppLayout() {
|
|||||||
"tenant_admin",
|
"tenant_admin",
|
||||||
"rp_admin",
|
"rp_admin",
|
||||||
].includes(currentRole);
|
].includes(currentRole);
|
||||||
const expiresAtSec = auth.user?.expires_at;
|
const sessionStatus = buildShellSessionStatus({
|
||||||
const remainingMs =
|
expiresAtSec: auth.user?.expires_at,
|
||||||
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
|
nowMs,
|
||||||
const remainingTotalSec =
|
t,
|
||||||
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 handleSessionExpiryToggle = () => {
|
const handleSessionExpiryToggle = () => {
|
||||||
setIsSessionExpiryEnabled((prev) => {
|
setIsSessionExpiryEnabled((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
|
writeShellSessionExpiryEnabled(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
|
<div className={shellLayoutClasses.root}>
|
||||||
<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">
|
<aside className={shellLayoutClasses.aside}>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
|
<div className={shellLayoutClasses.brandSection}>
|
||||||
<div className="flex items-center gap-3 md:flex-col md:items-start">
|
<div className={shellLayoutClasses.brandWrap}>
|
||||||
<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.brandIcon}>
|
||||||
<ShieldHalf size={20} />
|
<ShieldHalf size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -348,18 +304,18 @@ function AppLayout() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<BadgeCheck size={14} />
|
||||||
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
{t("ui.dev.scope_badge", "Scoped to /dev")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-2 pb-4 md:px-3 md:pb-8">
|
<nav className={shellLayoutClasses.navWrap}>
|
||||||
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
|
<div className={shellLayoutClasses.navMeta}>
|
||||||
<span className="rounded-full border border-border px-3 py-1">
|
<span className="rounded-full border border-border px-3 py-1">
|
||||||
{t("ui.dev.env_badge", "Env: dev")}
|
{t("ui.dev.env_badge", "Env: dev")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className={shellLayoutClasses.navList}>
|
||||||
{isDevConsoleAllowed &&
|
{isDevConsoleAllowed &&
|
||||||
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -367,10 +323,10 @@ function AppLayout() {
|
|||||||
to={to}
|
to={to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
shellLayoutClasses.navItemBase,
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
|
? shellLayoutClasses.navItemActive
|
||||||
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
: shellLayoutClasses.navItemIdle,
|
||||||
].join(" ")
|
].join(" ")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -387,13 +343,13 @@ function AppLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
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} />
|
<LogOut size={18} />
|
||||||
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
<span>{t("ui.dev.nav.logout", "Logout")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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("msg.dev.sidebar.notice", "Developer Console")}</p>
|
||||||
<p>
|
<p>
|
||||||
{t(
|
{t(
|
||||||
@@ -405,10 +361,10 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="relative">
|
<div className={shellLayoutClasses.content}>
|
||||||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
<header className={shellLayoutClasses.header}>
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
<div className={shellLayoutClasses.headerInner}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className={shellLayoutClasses.headerTitleWrap}>
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
{t("ui.dev.header.plane", "Dev Plane")}
|
{t("ui.dev.header.plane", "Dev Plane")}
|
||||||
</p>
|
</p>
|
||||||
@@ -416,12 +372,12 @@ function AppLayout() {
|
|||||||
{t("ui.dev.header.subtitle", "Manage your applications")}
|
{t("ui.dev.header.subtitle", "Manage your applications")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className={shellLayoutClasses.headerActions}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleTheme}
|
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", "테마 전환")}
|
aria-label={t("ui.common.theme_toggle", "테마 전환")}
|
||||||
>
|
>
|
||||||
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
|
||||||
@@ -432,11 +388,11 @@ function AppLayout() {
|
|||||||
{isSessionExpiryEnabled ? (
|
{isSessionExpiryEnabled ? (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
shellLayoutClasses.sessionBadge,
|
||||||
sessionToneClass,
|
sessionStatus.toneClass,
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{sessionText}
|
{sessionStatus.text}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative" ref={profileMenuRef}>
|
<div className="relative" ref={profileMenuRef}>
|
||||||
@@ -448,15 +404,15 @@ function AppLayout() {
|
|||||||
aria-expanded={isProfileMenuOpen}
|
aria-expanded={isProfileMenuOpen}
|
||||||
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
|
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">
|
<div className={shellLayoutClasses.profileInitial}>
|
||||||
{profileInitial}
|
{profileSummary.initial}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden min-w-0 text-left md:block">
|
<div className="hidden min-w-0 text-left md:block">
|
||||||
<p className="truncate text-xs font-medium text-foreground">
|
<p className="truncate text-xs font-medium text-foreground">
|
||||||
{profileName}
|
{profileSummary.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] text-muted-foreground">
|
<p className="truncate text-[11px] text-muted-foreground">
|
||||||
{profileEmail}
|
{profileSummary.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -465,20 +421,17 @@ function AppLayout() {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{isProfileMenuOpen ? (
|
{isProfileMenuOpen ? (
|
||||||
<div
|
<div role="menu" className={shellLayoutClasses.profileMenu}>
|
||||||
role="menu"
|
|
||||||
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
|
|
||||||
>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{t("ui.dev.profile.menu_title", "Account")}
|
{t("ui.dev.profile.menu_title", "Account")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
|
<div className={shellLayoutClasses.profileCard}>
|
||||||
<div>
|
<div>
|
||||||
<p className="truncate text-sm font-semibold text-foreground">
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
{profileName}
|
{profileSummary.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{profileEmail}
|
{profileSummary.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center pt-1">
|
<div className="flex items-center pt-1">
|
||||||
@@ -491,7 +444,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</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 className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
@@ -499,7 +452,7 @@ function AppLayout() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isSessionExpiryEnabled
|
{isSessionExpiryEnabled
|
||||||
? sessionText
|
? sessionStatus.text
|
||||||
: t(
|
: t(
|
||||||
"ui.dev.session.disabled",
|
"ui.dev.session.disabled",
|
||||||
"세션 만료 비활성화",
|
"세션 만료 비활성화",
|
||||||
@@ -555,7 +508,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
<main className={shellLayoutClasses.main}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,21 @@
|
|||||||
import { type VariantProps, cva } from "class-variance-authority";
|
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import {
|
||||||
|
type CommonBadgeVariant,
|
||||||
|
getCommonBadgeClasses,
|
||||||
|
} from "../../../../common/ui/badge";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
"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",
|
variant?: CommonBadgeVariant;
|
||||||
{
|
}
|
||||||
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> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
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 { Slot } from "@radix-ui/react-slot";
|
||||||
import { type VariantProps, cva } from "class-variance-authority";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
type CommonButtonSize,
|
||||||
|
type CommonButtonVariant,
|
||||||
|
getCommonButtonClasses,
|
||||||
|
} from "../../../../common/ui/button";
|
||||||
import { cn } from "../../lib/utils";
|
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
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
VariantProps<typeof buttonVariants> {
|
variant?: CommonButtonVariant;
|
||||||
|
size?: CommonButtonSize;
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(getCommonButtonClasses({ variant, size }), className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button };
|
||||||
|
|||||||
@@ -1,65 +1,51 @@
|
|||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import {
|
||||||
|
commonCardClass,
|
||||||
|
commonCardContentClass,
|
||||||
|
commonCardDescriptionClass,
|
||||||
|
commonCardFooterClass,
|
||||||
|
commonCardHeaderClass,
|
||||||
|
commonCardTitleClass,
|
||||||
|
} from "../../../../common/ui/card";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardClass, className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({
|
function CardHeader({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({
|
function CardTitle({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
return (
|
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
|
||||||
<h3
|
|
||||||
className={cn("text-lg font-semibold leading-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({
|
function CardDescription({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
return (
|
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
|
||||||
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({
|
function CardContent({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
return <div className={cn(commonCardContentClass, className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({
|
function CardFooter({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn(commonCardFooterClass, className)} {...props} />;
|
||||||
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { commonInputClass } from "../../../../common/ui/input";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(commonInputClass, className)}
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "../../common/theme/base.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@@ -24,72 +26,10 @@
|
|||||||
--input: 220 17% 90%;
|
--input: 220 17% 90%;
|
||||||
--ring: 209 79% 52%;
|
--ring: 209 79% 52%;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
}
|
--app-background-image: linear-gradient(
|
||||||
|
|
||||||
.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(
|
|
||||||
180deg,
|
180deg,
|
||||||
hsl(var(--background)) 0%,
|
hsl(var(--background)) 0%,
|
||||||
hsl(var(--secondary) / 0.35) 100%
|
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 { UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
import type { AuthProviderProps } from "react-oidc-context";
|
import type { AuthProviderProps } from "react-oidc-context";
|
||||||
import {
|
import {
|
||||||
buildOrgFrontAuthRedirectUris,
|
buildCommonOidcRuntimeConfig,
|
||||||
resolveOrgFrontPublicOrigin,
|
buildCommonUserManagerSettings,
|
||||||
} from "./authConfig";
|
} from "../../../common/core/auth";
|
||||||
|
import { resolveOrgFrontPublicOrigin } from "./authConfig";
|
||||||
|
|
||||||
const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin(
|
const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin(
|
||||||
import.meta.env.VITE_ORGFRONT_PUBLIC_URL,
|
import.meta.env.VITE_ORGFRONT_PUBLIC_URL,
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
);
|
);
|
||||||
const orgFrontRedirectUris =
|
|
||||||
buildOrgFrontAuthRedirectUris(orgFrontPublicOrigin);
|
|
||||||
|
|
||||||
export const oidcConfig: AuthProviderProps = {
|
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
||||||
authority:
|
authority:
|
||||||
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
|
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc",
|
||||||
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
|
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
|
||||||
redirect_uri: orgFrontRedirectUris.redirectUri,
|
origin: orgFrontPublicOrigin,
|
||||||
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,
|
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
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";
|
import { createTomlTranslator } from "../../../common/core/i18n";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import enRaw from "../locales/en.toml?raw";
|
import enRaw from "../locales/en.toml?raw";
|
||||||
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
// Vite ?raw import는 런타임 상수로 번들됩니다.
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import koRaw from "../locales/ko.toml?raw";
|
import koRaw from "../locales/ko.toml?raw";
|
||||||
|
|
||||||
const translations: Record<Locale, TomlObject> = {
|
export const t = createTomlTranslator({
|
||||||
ko: parseToml(koRaw),
|
ko: [commonKoRaw, koRaw],
|
||||||
en: parseToml(enRaw),
|
en: [commonEnRaw, 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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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_THRESHOLD_MS = 5 * 60 * 1000;
|
||||||
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
|
|
||||||
|
|
||||||
type SlidingSessionRenewDecisionParams = {
|
export function shouldAttemptSlidingSessionRenew(
|
||||||
expiresAtSec?: number | null;
|
params: SessionRenewDecisionParams,
|
||||||
nowMs: number;
|
) {
|
||||||
isEnabled: boolean;
|
return shouldAttemptSlidingSessionRenewBase({
|
||||||
isAuthenticated: boolean;
|
...params,
|
||||||
isLoading: boolean;
|
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
|
||||||
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({
|
export function shouldAttemptUnlimitedSessionRenew(
|
||||||
expiresAtSec,
|
params: SessionRenewDecisionParams,
|
||||||
nowMs,
|
) {
|
||||||
isEnabled,
|
return shouldAttemptUnlimitedSessionRenewBase({
|
||||||
isAuthenticated,
|
...params,
|
||||||
isLoading,
|
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { mergeClassNames } from "../../../common/core/utils";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
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]
|
[msg.admin.users.list.registry]
|
||||||
count = "Count"
|
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]
|
[msg.dev]
|
||||||
logout_confirm = "Are you sure you want to log out?"
|
logout_confirm = "Are you sure you want to log out?"
|
||||||
|
|
||||||
@@ -1176,73 +1167,6 @@ name = "Name"
|
|||||||
role = "Role"
|
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]
|
[test]
|
||||||
key = "Test"
|
key = "Test"
|
||||||
|
|
||||||
|
|||||||
@@ -302,15 +302,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다."
|
|||||||
[msg.admin.users.list.registry]
|
[msg.admin.users.list.registry]
|
||||||
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
|
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
|
||||||
|
|
||||||
[msg.common]
|
|
||||||
error = "오류가 발생했습니다."
|
|
||||||
loading = "로딩 중..."
|
|
||||||
no_description = "설명이 없습니다."
|
|
||||||
parsing = "데이터 파싱 중..."
|
|
||||||
requesting = "요청 중..."
|
|
||||||
saving = "저장 중..."
|
|
||||||
unknown_error = "알 수 없는 오류"
|
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = "로그아웃 하시겠습니까?"
|
logout_confirm = "로그아웃 하시겠습니까?"
|
||||||
|
|
||||||
@@ -1178,73 +1169,6 @@ name = "이름"
|
|||||||
role = "역할"
|
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]
|
[test]
|
||||||
key = "테스트"
|
key = "테스트"
|
||||||
|
|
||||||
|
|||||||
@@ -302,15 +302,6 @@ no_custom = ""
|
|||||||
[msg.admin.users.list.registry]
|
[msg.admin.users.list.registry]
|
||||||
count = ""
|
count = ""
|
||||||
|
|
||||||
[msg.common]
|
|
||||||
error = ""
|
|
||||||
loading = ""
|
|
||||||
no_description = ""
|
|
||||||
parsing = ""
|
|
||||||
requesting = ""
|
|
||||||
saving = ""
|
|
||||||
unknown_error = ""
|
|
||||||
|
|
||||||
[msg.dev]
|
[msg.dev]
|
||||||
logout_confirm = ""
|
logout_confirm = ""
|
||||||
|
|
||||||
@@ -1177,73 +1168,6 @@ name = ""
|
|||||||
role = ""
|
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]
|
[test]
|
||||||
key = ""
|
key = ""
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme";
|
|||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
|
"../common/**/*.{ts,tsx,css}",
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
center: true,
|
center: true,
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ if command -v rsync >/dev/null 2>&1; then
|
|||||||
--exclude 'playwright-report' \
|
--exclude 'playwright-report' \
|
||||||
--exclude 'test-results' \
|
--exclude 'test-results' \
|
||||||
"$repo_root/adminfront/" "$tmp_dir/adminfront/"
|
"$repo_root/adminfront/" "$tmp_dir/adminfront/"
|
||||||
|
rsync -rlptD --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
"$repo_root/common/" "$tmp_dir/common/"
|
||||||
else
|
else
|
||||||
cp -R "$repo_root/adminfront" "$tmp_dir/adminfront"
|
cp -R "$repo_root/adminfront" "$tmp_dir/adminfront"
|
||||||
|
cp -R "$repo_root/common" "$tmp_dir/common"
|
||||||
rm -rf "$tmp_dir/adminfront/node_modules" \
|
rm -rf "$tmp_dir/adminfront/node_modules" \
|
||||||
"$tmp_dir/adminfront/playwright-report" \
|
"$tmp_dir/adminfront/playwright-report" \
|
||||||
"$tmp_dir/adminfront/test-results"
|
"$tmp_dir/adminfront/test-results"
|
||||||
|
|||||||
@@ -5,11 +5,27 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const ROOT_DIR = process.cwd();
|
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 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([
|
const SKIP_DIRS = new Set([
|
||||||
'.git',
|
'.git',
|
||||||
'node_modules',
|
'node_modules',
|
||||||
@@ -78,7 +94,6 @@ function parseTomlKeys(filePath) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip quotes if present
|
|
||||||
if (key.startsWith('"') && key.endsWith('"')) {
|
if (key.startsWith('"') && key.endsWith('"')) {
|
||||||
key = key.slice(1, -1);
|
key = key.slice(1, -1);
|
||||||
}
|
}
|
||||||
@@ -138,6 +153,23 @@ function collectCodeKeys() {
|
|||||||
return keys;
|
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) {
|
function difference(aSet, bSet) {
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const item of aSet) {
|
for (const item of aSet) {
|
||||||
@@ -158,72 +190,75 @@ function printList(title, items) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function collectSpecResources(spec) {
|
||||||
const errors = [];
|
const templatePath = path.join(spec.dir, spec.template);
|
||||||
const warnings = [];
|
const templateResult = parseTomlKeys(templatePath);
|
||||||
|
|
||||||
const templateResult = parseTomlKeys(TEMPLATE_PATH);
|
|
||||||
if (!templateResult.ok) {
|
if (!templateResult.ok) {
|
||||||
errors.push(templateResult.error);
|
return { ok: false, error: templateResult.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
const langKeyMap = new Map();
|
const langKeyMap = new Map();
|
||||||
for (const fileName of LANG_FILES) {
|
for (const fileName of spec.langs) {
|
||||||
const langPath = path.join(LOCALES_DIR, fileName);
|
const langPath = path.join(spec.dir, fileName);
|
||||||
const langResult = parseTomlKeys(langPath);
|
const langResult = parseTomlKeys(langPath);
|
||||||
if (!langResult.ok) {
|
if (!langResult.ok) {
|
||||||
errors.push(langResult.error);
|
return { ok: false, error: langResult.error };
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
langKeyMap.set(fileName, langResult.keys);
|
langKeyMap.set(fileName, langResult.keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
return {
|
||||||
console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.');
|
ok: true,
|
||||||
for (const error of errors) {
|
templateKeys: templateResult.keys,
|
||||||
console.error(`- ${error}`);
|
langKeyMap,
|
||||||
}
|
};
|
||||||
process.exit(1);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const templateKeys = templateResult.keys;
|
function main() {
|
||||||
const rawCodeKeys = Array.from(collectCodeKeys());
|
const errors = [];
|
||||||
const codeKeysArray = rawCodeKeys.filter(k =>
|
const warnings = [];
|
||||||
!k.includes('.msg.') &&
|
|
||||||
!k.includes('.ui.') &&
|
const rawCodeKeys = Array.from(collectCodeKeys()).filter(
|
||||||
!k.includes('.err.') &&
|
(key) => !shouldIgnoreCodeKey(key),
|
||||||
!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.")
|
|
||||||
);
|
);
|
||||||
const codeKeys = new Set(codeKeysArray);
|
const codeKeys = new Set(rawCodeKeys);
|
||||||
|
|
||||||
for (const [fileName, langKeys] of langKeyMap.entries()) {
|
for (const spec of LOCALE_SPECS) {
|
||||||
const missingInLang = difference(templateKeys, langKeys);
|
const resources = collectSpecResources(spec);
|
||||||
if (missingInLang.length > 0) {
|
if (!resources.ok) {
|
||||||
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`);
|
errors.push(resources.error);
|
||||||
printList(`${fileName}에 없는 키`, missingInLang);
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const missingInTemplate = difference(codeKeys, templateKeys);
|
for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
|
||||||
if (missingInTemplate.length > 0) {
|
const missingInLang = difference(resources.templateKeys, langKeys);
|
||||||
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`);
|
if (missingInLang.length > 0) {
|
||||||
printList('template.toml에 없는 코드 사용 키', missingInTemplate);
|
errors.push(
|
||||||
}
|
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`,
|
||||||
|
);
|
||||||
|
printList(`${spec.label} ${fileName}에 없는 키`, missingInLang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const unusedInTemplate = difference(templateKeys, codeKeys);
|
const ownedCodeKeys = new Set(
|
||||||
if (unusedInTemplate.length > 0) {
|
rawCodeKeys.filter((key) => spec.ownsKey(key)),
|
||||||
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`);
|
);
|
||||||
printList('코드에서 사용되지 않는 키', unusedInTemplate);
|
|
||||||
|
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) {
|
if (errors.length > 0) {
|
||||||
|
|||||||
@@ -5,9 +5,25 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const ROOT_DIR = process.cwd();
|
const ROOT_DIR = process.cwd();
|
||||||
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
|
|
||||||
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
|
const LOCALE_SPECS = [
|
||||||
const LANG_FILES = ['ko.toml', 'en.toml'];
|
{
|
||||||
|
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([
|
const SKIP_DIRS = new Set([
|
||||||
'.git',
|
'.git',
|
||||||
@@ -81,7 +97,6 @@ function parseTomlKeys(filePath) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip quotes if present
|
|
||||||
if (key.startsWith('"') && key.endsWith('"')) {
|
if (key.startsWith('"') && key.endsWith('"')) {
|
||||||
key = key.slice(1, -1);
|
key = key.slice(1, -1);
|
||||||
}
|
}
|
||||||
@@ -141,22 +156,20 @@ function collectCodeKeys() {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterCodeKeys(rawKeys) {
|
function shouldIgnoreCodeKey(key) {
|
||||||
return Array.from(rawKeys).filter((k) =>
|
return (
|
||||||
!k.includes('.msg.') &&
|
key.includes('.msg.') ||
|
||||||
!k.includes('.ui.') &&
|
key.includes('.ui.') ||
|
||||||
!k.includes('.err.') &&
|
key.includes('.err.') ||
|
||||||
!k.includes('.test.') &&
|
key.includes('.test.') ||
|
||||||
!k.includes('.non.') &&
|
key.includes('.non.') ||
|
||||||
!k.startsWith('ui.admin.users.list.table.') &&
|
key.startsWith('ui.admin.users.list.table.') ||
|
||||||
!k.startsWith('msg.admin.users.detail.') &&
|
key.startsWith('msg.admin.users.detail.') ||
|
||||||
!k.startsWith('msg.common.') &&
|
key.startsWith('msg.dev.clients.') ||
|
||||||
!k.startsWith('msg.dev.clients.') &&
|
key.startsWith('ui.admin.users.create.') ||
|
||||||
!k.startsWith('ui.admin.users.create.') &&
|
key.startsWith('ui.admin.users.detail.') ||
|
||||||
!k.startsWith('ui.admin.users.detail.') &&
|
key.startsWith('ui.dev.clients.') ||
|
||||||
!k.startsWith('ui.common.') &&
|
key.startsWith('ui.dev.session.')
|
||||||
!k.startsWith('ui.dev.clients.') &&
|
|
||||||
!k.startsWith('ui.dev.session.')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,62 +183,82 @@ function difference(aSet, bSet) {
|
|||||||
return result.sort();
|
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() {
|
function buildReport() {
|
||||||
const report = {
|
const report = {
|
||||||
generated_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
errors: [],
|
errors: [],
|
||||||
warnings: [],
|
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_template: [],
|
||||||
missing_in_lang: {},
|
missing_in_lang: {},
|
||||||
unused_in_template: [],
|
unused_in_template: [],
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const templateResult = parseTomlKeys(TEMPLATE_PATH);
|
if (!resources.ok) {
|
||||||
if (!templateResult.ok) {
|
report.errors.push(resources.error);
|
||||||
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);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
langKeyMap.set(fileName, langResult.keys);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [fileName, langKeys] of langKeyMap.entries()) {
|
for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
|
||||||
const missingInLang = difference(templateKeys, langKeys);
|
const missingInLang = difference(resources.templateKeys, langKeys);
|
||||||
if (missingInLang.length > 0) {
|
if (missingInLang.length > 0) {
|
||||||
report.errors.push(
|
report.errors.push(
|
||||||
`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`,
|
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}개`,
|
||||||
);
|
);
|
||||||
report.details.missing_in_lang[fileName] = missingInLang;
|
report.details[spec.name].missing_in_lang[fileName] = missingInLang;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const missingInTemplate = difference(codeKeys, templateKeys);
|
const ownedCodeKeys = new Set(rawCodeKeys.filter((key) => spec.ownsKey(key)));
|
||||||
if (missingInTemplate.length > 0) {
|
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
|
||||||
report.errors.push(
|
if (missingInTemplate.length > 0) {
|
||||||
`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`,
|
report.errors.push(
|
||||||
);
|
`[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}개`,
|
||||||
report.details.missing_in_template = missingInTemplate;
|
);
|
||||||
}
|
report.details[spec.name].missing_in_template = missingInTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
const unusedInTemplate = difference(templateKeys, codeKeys);
|
const unusedInTemplate = difference(resources.templateKeys, codeKeys);
|
||||||
if (unusedInTemplate.length > 0) {
|
if (unusedInTemplate.length > 0) {
|
||||||
report.warnings.push(
|
report.warnings.push(
|
||||||
`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`,
|
`[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}개`,
|
||||||
);
|
);
|
||||||
report.details.unused_in_template = unusedInTemplate;
|
report.details[spec.name].unused_in_template = unusedInTemplate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return report;
|
return report;
|
||||||
@@ -258,8 +291,12 @@ function main() {
|
|||||||
fs.writeFileSync(summaryPath, lines.join('\n'));
|
fs.writeFileSync(summaryPath, lines.join('\n'));
|
||||||
|
|
||||||
if (report.errors.length > 0) {
|
if (report.errors.length > 0) {
|
||||||
|
console.error('❌ i18n report generated with errors');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`✅ i18n report written to ${outPath}`);
|
||||||
|
console.log(`✅ i18n summary written to ${summaryPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
@@ -95,10 +95,14 @@ async function mockUserfrontApis(
|
|||||||
pendingRef?: string;
|
pendingRef?: string;
|
||||||
};
|
};
|
||||||
pendingRef = body.pendingRef ?? null;
|
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;
|
pendingRef = null;
|
||||||
}
|
}
|
||||||
options.captureApprove?.(pendingRef);
|
options.captureApprove?.(pendingRef);
|
||||||
|
} else {
|
||||||
|
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`);
|
||||||
}
|
}
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.dart';
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
|
import '../../../../core/services/web_window.dart';
|
||||||
|
|
||||||
class ApproveQrScreen extends StatefulWidget {
|
class ApproveQrScreen extends StatefulWidget {
|
||||||
final String? pendingRef;
|
final String? pendingRef;
|
||||||
@@ -60,7 +61,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final target = buildLocalizedSigninPath(Uri.base);
|
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 (storedToken == null && !hasCookie) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final target = buildLocalizedSigninPath(Uri.base);
|
final target = buildLocalizedSigninPath(Uri.base);
|
||||||
context.go('$target?notice=qr_login_required');
|
webWindow.redirectTo('$target?notice=qr_login_required');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,20 @@ Map<String, dynamic>? _decodeErrorDetails(String? raw) {
|
|||||||
return null;
|
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({
|
void _attemptRecoveryFromNullCheck({
|
||||||
required Object exception,
|
required Object exception,
|
||||||
StackTrace? stackTrace,
|
StackTrace? stackTrace,
|
||||||
@@ -243,25 +257,18 @@ final _router = GoRouter(
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/:locale',
|
path: '/:locale',
|
||||||
redirect: (context, state) {
|
builder: (context, state) {
|
||||||
// /{locale} 진입은 화면 렌더링 없이 단일 목적지로만 보냅니다.
|
|
||||||
if (state.uri.pathSegments.length != 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final rawLocale = state.pathParameters['locale'];
|
final rawLocale = state.pathParameters['locale'];
|
||||||
final localeCode = normalizeLocaleCode(rawLocale);
|
final localeCode = normalizeLocaleCode(rawLocale);
|
||||||
final token = AuthTokenStore.getToken();
|
return ScopedTheme(
|
||||||
final isLoggedIn =
|
controller: ThemeController.auth,
|
||||||
(token != null && token.isNotEmpty) ||
|
child: LocaleEntryRedirectScreen(localeCode: localeCode),
|
||||||
AuthTokenStore.usesCookie();
|
);
|
||||||
if (!isLoggedIn) {
|
|
||||||
return buildSigninRedirectPath(localeCode, state.uri);
|
|
||||||
}
|
|
||||||
return '/$localeCode/dashboard';
|
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
|
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return ScopedTheme(
|
return ScopedTheme(
|
||||||
controller: ThemeController.app,
|
controller: ThemeController.app,
|
||||||
@@ -271,6 +278,7 @@ final _router = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
|
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||||
builder: (context, state) => ScopedTheme(
|
builder: (context, state) => ScopedTheme(
|
||||||
controller: ThemeController.app,
|
controller: ThemeController.app,
|
||||||
child: const ProfilePage(),
|
child: const ProfilePage(),
|
||||||
@@ -430,6 +438,19 @@ final _router = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'approve',
|
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(
|
builder: (context, state) => ScopedTheme(
|
||||||
controller: ThemeController.auth,
|
controller: ThemeController.auth,
|
||||||
child: ApproveQrScreen(
|
child: ApproveQrScreen(
|
||||||
@@ -439,6 +460,17 @@ final _router = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'ql/:ref',
|
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(
|
builder: (context, state) => ScopedTheme(
|
||||||
controller: ThemeController.auth,
|
controller: ThemeController.auth,
|
||||||
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
||||||
@@ -446,6 +478,7 @@ final _router = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'scan',
|
path: 'scan',
|
||||||
|
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||||
builder: (context, state) => ScopedTheme(
|
builder: (context, state) => ScopedTheme(
|
||||||
controller: ThemeController.auth,
|
controller: ThemeController.auth,
|
||||||
child: const QRScanScreen(),
|
child: const QRScanScreen(),
|
||||||
@@ -453,6 +486,7 @@ final _router = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
|
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
|
||||||
builder: (context, state) => ScopedTheme(
|
builder: (context, state) => ScopedTheme(
|
||||||
controller: ThemeController.app,
|
controller: ThemeController.app,
|
||||||
child: const UserManagementScreen(),
|
child: const UserManagementScreen(),
|
||||||
@@ -478,6 +512,10 @@ final _router = GoRouter(
|
|||||||
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
|
||||||
final path = stripLocalePath(uri);
|
final path = stripLocalePath(uri);
|
||||||
|
|
||||||
|
if (!isLoggedIn && (path == '/approve' || path.startsWith('/ql/'))) {
|
||||||
|
return '/$requestedLocale/signin?notice=qr_login_required';
|
||||||
|
}
|
||||||
|
|
||||||
final isPublicPath = isPublicAuthPath(path, uri);
|
final isPublicPath = isPublicAuthPath(path, uri);
|
||||||
|
|
||||||
if (isPublicPath) {
|
if (isPublicPath) {
|
||||||
@@ -499,9 +537,24 @@ final _router = GoRouter(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
class BaronSSOApp extends StatelessWidget {
|
class BaronSSOApp extends StatefulWidget {
|
||||||
const BaronSSOApp({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final localization = EasyLocalization.of(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
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
cli_config:
|
cli_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -276,6 +276,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -328,18 +336,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -661,26 +669,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.30.0"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.16"
|
version: "0.6.12"
|
||||||
toml:
|
toml:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user