1
0
forked from baron/baron-sso

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

This commit is contained in:
2026-05-12 18:03:08 +09:00
86 changed files with 3749 additions and 2834 deletions

View File

@@ -541,11 +541,12 @@ KETO_WRITE_URL = "http://keto:4467"
```
## 🌐 i18n 구조 (간략)
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
- **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml``ui.common.*`, `msg.common.*` 같은 React 공통 문구 레이어입니다.
- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`를 사용하며 `common locale -> app locale override` 순서로 TOML을 `?raw` 로드합니다.
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml``tools/i18n-scanner/gen-flutter-i18n.js``userfront/lib/i18n_data.dart`에 사전 생성합니다.
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
- **검증**: `node tools/i18n-scanner/index.js` `root locales``common/locales` 코드-키-로케일 동기화 상태를 함께 점검합니다.
## 🧪 Code Check CI
워크플로우 파일: `.gitea/workflows/code_check.yml`

View File

@@ -1,11 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
defaultOptions: queryClientDefaultOptions,
});

View File

@@ -26,6 +26,15 @@ import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import {
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
@@ -62,15 +71,11 @@ function AppLayout() {
const mockRoleOverride = isMockRoleEnabled
? window.localStorage.getItem("X-Mock-Role")
: null;
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
return stored !== "false";
});
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
@@ -214,14 +219,7 @@ function AppLayout() {
}, [auth.user]);
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
@@ -388,68 +386,26 @@ function AppLayout() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileName =
profile?.name?.trim() ||
auth.user?.profile.name?.toString().trim() ||
auth.user?.profile.preferred_username?.toString().trim() ||
t("ui.dev.profile.unknown_name", "Unknown User");
const profileEmail =
profile?.email?.trim() ||
auth.user?.profile.email?.toString().trim() ||
t("ui.dev.profile.unknown_email", "unknown@example.com");
const profileInitial = profileName.charAt(0).toUpperCase();
const profileSummary = buildShellProfileSummary({
profileName:
profile?.name ||
auth.user?.profile.name?.toString() ||
auth.user?.profile.preferred_username?.toString(),
profileEmail: profile?.email || auth.user?.profile.email?.toString(),
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
});
const profileRoleKey = mockRoleOverride || profile?.role || "user";
const expiresAtSec = auth.user?.expires_at;
const remainingMs =
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
const remainingTotalSec =
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
const remainingMinutes =
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
const remainingSeconds =
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "세션 활성");
if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "알 수 없음");
} else if (remainingMs <= 0) {
sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "세션 만료");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
remainingMinutes <= 5
) {
sessionToneClass =
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
sessionText = t(
"ui.dev.session.expiring",
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
},
);
} else {
sessionText = t(
"ui.dev.session.remaining",
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
},
);
}
const sessionStatus = buildShellSessionStatus({
expiresAtSec: auth.user?.expires_at,
nowMs,
t,
});
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
writeShellSessionExpiryEnabled(next);
return next;
});
};
@@ -463,11 +419,11 @@ function AppLayout() {
}
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<div className={shellLayoutClasses.root}>
<aside className={shellLayoutClasses.asideStatic}>
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>
<ShieldHalf size={20} />
</div>
<div>
@@ -480,8 +436,8 @@ function AppLayout() {
</div>
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-col gap-1">
<nav className={shellLayoutClasses.navWrap}>
<div className={shellLayoutClasses.navList}>
{navItems.map((item: NavItem) => {
const { label, to, icon: Icon, isExternal } = item;
const isOrgChart = location.pathname === "/tenants/org-chart";
@@ -499,7 +455,10 @@ function AppLayout() {
href={to}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
className={[
shellLayoutClasses.navItemBase,
shellLayoutClasses.navItemIdle,
].join(" ")}
>
<Icon size={18} />
<span>{t(label, label)}</span>
@@ -513,10 +472,10 @@ function AppLayout() {
to={to}
className={() =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
shellLayoutClasses.navItemBase,
isCustomActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
@@ -531,7 +490,7 @@ function AppLayout() {
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
@@ -540,10 +499,10 @@ function AppLayout() {
</nav>
</aside>
<div className="relative min-w-0">
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<div className={shellLayoutClasses.contentWide}>
<header className={shellLayoutClasses.headerElevated}>
<div className={shellLayoutClasses.headerInner}>
<div className={shellLayoutClasses.headerTitleWrap}>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.admin.header.plane", "ADMIN PLANE")}
</p>
@@ -552,12 +511,12 @@ function AppLayout() {
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div className={shellLayoutClasses.headerActions}>
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "테마 전환")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
@@ -568,11 +527,11 @@ function AppLayout() {
{isSessionExpiryEnabled ? (
<span
className={[
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
sessionToneClass,
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionText}
{sessionStatus.text}
</span>
) : null}
<div className="relative" ref={profileMenuRef}>
@@ -584,15 +543,15 @@ function AppLayout() {
aria-expanded={isProfileOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profileInitial}
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<ChevronDown
@@ -602,20 +561,17 @@ function AppLayout() {
</button>
{isProfileOpen ? (
<div
role="menu"
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
>
<div role="menu" className={shellLayoutClasses.profileMenu}>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</p>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.profileCard}>
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<div className="flex items-center pt-1">
@@ -628,7 +584,7 @@ function AppLayout() {
</div>
</div>
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
@@ -636,7 +592,7 @@ function AppLayout() {
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
? sessionStatus.text
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
@@ -736,7 +692,7 @@ function AppLayout() {
</div>
</div>
</header>
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
<main className={shellLayoutClasses.mainMinWidth}>
<Outlet />
</main>
<RoleSwitcher />

View File

@@ -1,38 +1,21 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "text-foreground",
muted: "border-border bg-secondary/60 text-muted-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: CommonBadgeVariant;
}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div
className={cn(getCommonBadgeClasses({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
export { Badge };

View File

@@ -1,41 +1,16 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import {
type CommonButtonSize,
type CommonButtonVariant,
getCommonButtonClasses,
} from "../../../../common/ui/button";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: CommonButtonVariant;
size?: CommonButtonSize;
asChild?: boolean;
}
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(getCommonButtonClasses({ variant, size }), className)}
ref={ref}
{...props}
/>
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button };

View File

@@ -1,65 +1,51 @@
import type * as React from "react";
import {
commonCardClass,
commonCardContentClass,
commonCardDescriptionClass,
commonCardFooterClass,
commonCardHeaderClass,
commonCardTitleClass,
} from "../../../../common/ui/card";
import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
className,
)}
{...props}
/>
);
return <div className={cn(commonCardClass, className)} {...props} />;
}
function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
);
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
}
function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
}
function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
}
function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
return <div className={cn(commonCardContentClass, className)} {...props} />;
}
function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
return <div className={cn(commonCardFooterClass, className)} {...props} />;
}
export {

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { commonInputClass } from "../../../../common/ui/input";
import { cn } from "../../lib/utils";
export interface InputProps
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
className={cn(commonInputClass, className)}
ref={ref}
{...props}
/>

View File

@@ -21,6 +21,12 @@ import {
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import {
sortItems,
toggleSort,
type SortConfig,
type SortResolverMap,
} from "../../../../../common/core/utils";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
@@ -82,10 +88,7 @@ import {
const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
type SortConfig = {
key: keyof TenantSummary | "recursiveMemberCount";
direction: "asc" | "desc";
};
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
@@ -225,7 +228,8 @@ function TenantListPage() {
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
const [sortConfig, setSortConfig] =
React.useState<SortConfig<TenantSortKey> | null>(null);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [importMessage, setImportMessage] = React.useState("");
const [previewRows, setPreviewRows] = React.useState<
@@ -363,6 +367,17 @@ function TenantListPage() {
const allTenants = query.data?.items ?? [];
const importParentOptionGroups =
buildTenantImportParentOptionGroups(allTenants);
const tenantSortResolvers = React.useMemo<
SortResolverMap<
TenantSummary & { recursiveMemberCount: number },
TenantSortKey
>
>(
() => ({
recursiveMemberCount: (tenant) => tenant.recursiveMemberCount,
}),
[],
);
const tenants = React.useMemo(() => {
// 1. Calculate recursive counts
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
@@ -396,38 +411,14 @@ function TenantListPage() {
);
}
if (sortConfig) {
enriched.sort((a, b) => {
const aValue = a[sortConfig.key as keyof typeof a];
const bValue = b[sortConfig.key as keyof typeof b];
return sortItems(enriched, sortConfig, tenantSortResolvers);
}, [allTenants, search, sortConfig, tenantSortResolvers]);
if (aValue === bValue) return 0;
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
if (sortConfig.direction === "asc") {
return aValue < bValue ? -1 : 1;
}
return aValue > bValue ? -1 : 1;
});
}
return enriched;
}, [allTenants, search, sortConfig]);
const requestSort = (key: SortConfig["key"]) => {
let direction: "asc" | "desc" = "asc";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc";
}
setSortConfig({ key, direction });
const requestSort = (key: TenantSortKey) => {
setSortConfig((current) => toggleSort(current, key));
};
const getSortIcon = (key: SortConfig["key"]) => {
const getSortIcon = (key: TenantSortKey) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}

View File

@@ -50,6 +50,12 @@ import {
TableRow,
} from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import {
sortItems,
toggleSort,
type SortConfig,
type SortResolverMap,
} from "../../../../common/core/utils";
import {
type UserSummary,
bulkDeleteUsers,
@@ -71,10 +77,7 @@ type UserSchemaField = {
type: string;
};
type SortConfig = {
key: string;
direction: "asc" | "desc";
};
type UserSortKey = string;
function UserListPage() {
const navigate = useNavigate();
@@ -86,7 +89,8 @@ function UserListPage() {
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null);
const limit = 1000;
const offset = (page - 1) * limit;
@@ -219,60 +223,40 @@ function UserListPage() {
: null;
const rawItems = query.data?.items ?? [];
const userSortResolvers = React.useMemo<
SortResolverMap<UserSummary, UserSortKey>
>(
() =>
userSchema.reduce<SortResolverMap<UserSummary, UserSortKey>>(
(accumulator, field) => {
accumulator[field.key] = (user) => {
const value = user.metadata?.[field.key];
return typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
? value
: null;
};
return accumulator;
},
{
name_email: (user) =>
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
tenant_dept: (user) =>
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`,
},
),
[userSchema],
);
const items = React.useMemo(() => {
const sorted = [...rawItems];
if (sortConfig) {
sorted.sort((a, b) => {
let aValue: string | number | boolean | null | undefined;
let bValue: string | number | boolean | null | undefined;
return sortItems(rawItems, sortConfig, userSortResolvers);
}, [rawItems, sortConfig, userSortResolvers]);
if (sortConfig.key === "name_email") {
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
} else if (sortConfig.key === "tenant_dept") {
aValue =
(a.tenant?.name || a.tenantSlug || "").toLowerCase() +
(a.department || "").toLowerCase();
bValue =
(b.tenant?.name || b.tenantSlug || "").toLowerCase() +
(b.department || "").toLowerCase();
} else {
aValue = (a as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
bValue = (b as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
}
if (aValue === bValue) return 0;
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
if (sortConfig.direction === "asc") {
return aValue < bValue ? -1 : 1;
}
return aValue > bValue ? -1 : 1;
});
}
return sorted;
}, [rawItems, sortConfig]);
const requestSort = (key: SortConfig["key"]) => {
let direction: "asc" | "desc" = "asc";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc";
}
setSortConfig({ key, direction });
const requestSort = (key: UserSortKey) => {
setSortConfig((current) => toggleSort(current, key));
};
const getSortIcon = (key: SortConfig["key"]) => {
const getSortIcon = (key: UserSortKey) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}

View File

@@ -1,3 +1,5 @@
@import "../../common/theme/base.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -24,37 +26,7 @@
--input: 215 25% 24%;
--ring: 209 79% 52%;
--radius: 0.75rem;
}
.light {
--background: 0 0% 98%;
--foreground: 223 25% 12%;
--card: 0 0% 100%;
--card-foreground: 223 25% 12%;
--popover: 0 0% 100%;
--popover-foreground: 223 25% 12%;
--primary: 209 79% 52%;
--primary-foreground: 0 0% 100%;
--secondary: 220 17% 94%;
--secondary-foreground: 223 25% 20%;
--muted: 223 15% 45%;
--muted-foreground: 223 15% 45%;
--accent: 40 96% 62%;
--accent-foreground: 223 25% 12%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 17% 90%;
--input: 220 17% 90%;
--ring: 209 79% 52%;
}
* {
@apply border-border;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
--app-background-image: radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%
@@ -70,14 +42,4 @@
transparent 30%
);
}
a {
@apply text-inherit no-underline;
}
}
@layer components {
.glass-panel {
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
}
}

View File

@@ -1,31 +1,23 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
import {
buildAdminAuthRedirectUris,
resolveAdminPublicOrigin,
} from "./authConfig";
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
} from "../../../common/core/auth";
import { resolveAdminPublicOrigin } from "./authConfig";
const adminPublicOrigin = resolveAdminPublicOrigin(
import.meta.env.VITE_ADMIN_PUBLIC_URL,
window.location.origin,
);
const adminRedirectUris = buildAdminAuthRedirectUris(adminPublicOrigin);
export const oidcConfig: AuthProviderProps = {
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
redirect_uri: adminRedirectUris.redirectUri,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: adminRedirectUris.postLogoutRedirectUri,
popup_redirect_uri: adminRedirectUris.popupRedirectUri,
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
origin: adminPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
};
export const userManager = new UserManager({
...oidcConfig,
authority: oidcConfig.authority || "",
client_id: oidcConfig.client_id || "",
redirect_uri: oidcConfig.redirect_uri || "",
});
export const userManager = new UserManager(
buildCommonUserManagerSettings(oidcConfig),
);

View File

@@ -1,148 +1,21 @@
const LOCALE_STORAGE_KEY = "locale";
const DEFAULT_LOCALE = "ko";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
type TomlValue = string | TomlObject;
interface TomlObject {
[key: string]: TomlValue;
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function parseToml(raw: string): TomlObject {
const lines = raw.split(/\r?\n/);
const root: TomlObject = {};
let currentPath: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const sectionName = line.slice(1, -1).trim();
currentPath = sectionName
? sectionName
.split(".")
.map((part) => part.trim())
.filter(Boolean)
: [];
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = line.slice(0, eqIndex).trim();
const valueRaw = line.slice(eqIndex + 1).trim();
if (!key) {
continue;
}
let value = valueRaw;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
let cursor: TomlObject = root;
for (const section of currentPath) {
if (!cursor[section] || typeof cursor[section] === "string") {
cursor[section] = {};
}
cursor = cursor[section] as TomlObject;
}
cursor[key] = value;
}
return root;
}
function getValue(target: TomlObject, key: string): string | undefined {
const parts = key.split(".");
let cursor: TomlValue = target;
for (const part of parts) {
if (typeof cursor !== "object" || cursor === null) {
return undefined;
}
cursor = (cursor as TomlObject)[part];
if (cursor === undefined) {
return undefined;
}
}
return typeof cursor === "string" ? cursor : undefined;
}
function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale && isSupportedLocale(pathLocale)) {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
if (browserLang.startsWith("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
import { createTomlTranslator } from "../../../common/core/i18n";
// eslint-disable-next-line import/no-unresolved
import commonEnRaw from "../../../common/locales/en.toml?raw";
// eslint-disable-next-line import/no-unresolved
import commonKoRaw from "../../../common/locales/ko.toml?raw";
// eslint-disable-next-line import/no-unresolved
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../locales/ko.toml?raw";
const translations: Record<Locale, TomlObject> = {
ko: parseToml(koRaw),
en: parseToml(enRaw),
};
function formatTemplate(
template: string,
vars?: Record<string, string | number>,
): string {
if (!vars) {
return template;
}
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
export function t(
key: string,
fallback?: string,
vars?: Record<string, string | number>,
): string {
const locale = detectLocale();
const value = getValue(translations[locale], key);
if (value && value.length > 0) {
return formatTemplate(value, vars);
}
return formatTemplate(fallback ?? key, vars);
}
export const t = createTomlTranslator(
{
ko: [commonKoRaw, koRaw],
en: [commonEnRaw, enRaw],
},
{
normalizeEscapedNewlines: true,
},
);

View File

@@ -1,106 +1,6 @@
export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
type SlidingSessionRenewDecisionParams = {
expiresAtSec?: number | null;
nowMs: number;
isEnabled: boolean;
isAuthenticated: boolean;
isLoading: boolean;
isRenewInFlight: boolean;
lastAttemptAtMs: number;
thresholdMs?: number;
throttleMs?: number;
};
export function shouldAttemptSlidingSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
console.debug(
"[sessionSliding] expiresAtSec is not a number, skipping renew",
);
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
const remainingMin = Math.floor(remainingMs / 1000 / 60);
if (remainingMs <= 0) {
console.debug("[sessionSliding] Session already expired, skipping renew");
return false;
}
if (remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
console.debug("[sessionSliding] Throttling renewal attempt");
return false;
}
console.info(
`[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`,
);
return true;
}
export function shouldAttemptUnlimitedSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
console.debug(
"[sessionSliding] expiresAtSec is not a number, skipping unlimited renew",
);
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
const remainingMin = Math.floor(remainingMs / 1000 / 60);
if (remainingMs <= 0) {
console.debug(
"[sessionSliding] Session already expired, skipping unlimited renew",
);
return false;
}
if (remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
console.debug("[sessionSliding] Throttling unlimited renewal attempt");
return false;
}
console.info(
`[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`,
);
return true;
}
export {
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../../common/core/session";

View 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"]);
});
});

View File

@@ -1,8 +1,10 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { mergeClassNames } from "../../../common/core/utils";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return mergeClassNames(twMerge, [clsx(inputs)]);
}
export function generateSecurePassword(length = 16): string {

View File

@@ -327,15 +327,6 @@ no_custom = "No custom fields defined for this tenant."
[msg.admin.users.list.registry]
count = "Count"
[msg.common]
error = "Error"
loading = "Loading..."
no_description = "No Description."
parsing = "Parsing data..."
requesting = "Requesting..."
saving = "Saving..."
unknown_error = "unknown error"
[msg.dev]
logout_confirm = "Are you sure you want to log out?"
@@ -1281,63 +1272,6 @@ name = "Name"
role = "Role"
[ui.common]
add = "Add"
all = "All"
admin_only = "Admin Only"
assign = "Assign"
back = "Back"
cancel = "Cancel"
change_file = "Change File"
clear_search = "Clear Search"
close = "Close"
collapse = "Collapse"
confirm = "Confirm"
copy = "Copy"
create = "Create"
delete = "Delete"
details = "Details"
edit = "Edit"
export = "Export"
fail = "Fail"
go_home = "Go Home"
view = "View"
hyphen = "-"
manage = "Manage"
na = "N/A"
never = "Never"
next = "Next"
none = "None"
page_of = "Page {{page}} of {{total}}"
prev = "Prev"
previous = "Previous"
qr = "QR"
reset = "Reset"
read_only = "Read Only"
refresh = "Refresh"
remove = "Remove"
resend = "Resend"
retry = "Retry"
save = "Save"
search = "Search"
select = "Select"
select_file = "Select File"
select_placeholder = "Select Placeholder"
show_more = "Show More"
language = "Language"
language_ko = "Korean"
language_en = "English"
success = "Success"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "Theme Toggle"
unknown = "Unknown"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.role]
admin = "Admin"
rp_admin = "RP Admin"

View File

@@ -329,15 +329,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다."
[msg.admin.users.list.registry]
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
[msg.common]
error = "오류가 발생했습니다."
loading = "로딩 중..."
no_description = "설명이 없습니다."
parsing = "데이터 파싱 중..."
requesting = "요청 중..."
saving = "저장 중..."
unknown_error = "알 수 없는 오류"
[msg.dev]
logout_confirm = "로그아웃 하시겠습니까?"
@@ -1283,63 +1274,6 @@ name = "이름"
role = "역할"
[ui.common]
add = "추가"
all = "전체"
admin_only = "관리자 전용"
assign = "할당"
back = "돌아가기"
cancel = "취소"
change_file = "파일 변경"
clear_search = "검색 초기화"
close = "닫기"
collapse = "접기"
confirm = "확인"
copy = "복사"
create = "생성"
delete = "삭제"
details = "상세정보"
edit = "편집"
export = "내보내기"
fail = "실패"
go_home = "홈으로"
view = "보기"
hyphen = "-"
manage = "관리"
na = "N/A"
never = "Never"
next = "다음"
none = "없음"
page_of = "Page {{page}} of {{total}}"
prev = "이전"
previous = "이전"
qr = "QR"
reset = "초기화"
read_only = "읽기 전용"
refresh = "새로고침"
remove = "제외"
resend = "재발송"
retry = "다시 시도"
save = "저장"
search = "검색"
select = "선택"
select_file = "파일 선택"
select_placeholder = "선택하세요"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
success = "성공"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
unknown = "Unknown"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.role]
admin = "Admin"
rp_admin = "RP Admin"

View File

@@ -337,15 +337,6 @@ no_custom = ""
[msg.admin.users.list.registry]
count = ""
[msg.common]
error = ""
loading = ""
no_description = ""
parsing = ""
requesting = ""
saving = ""
unknown_error = ""
[msg.dev]
logout_confirm = ""
@@ -1261,63 +1252,6 @@ name = ""
role = ""
[ui.common]
add = ""
all = ""
admin_only = ""
assign = ""
back = ""
cancel = ""
change_file = ""
clear_search = ""
close = ""
collapse = ""
confirm = ""
copy = ""
create = ""
delete = ""
details = ""
edit = ""
export = ""
fail = ""
go_home = ""
view = ""
hyphen = ""
manage = ""
na = ""
never = ""
next = ""
none = ""
page_of = ""
prev = ""
previous = ""
qr = ""
reset = ""
read_only = ""
refresh = ""
remove = ""
resend = ""
retry = ""
save = ""
search = ""
select = ""
select_file = ""
select_placeholder = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
success = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
unknown = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.role]
admin = ""
rp_admin = ""

View File

@@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme";
const config: Config = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
"../common/**/*.{ts,tsx,css}",
],
theme: {
container: {
center: true,

View File

@@ -376,6 +376,7 @@ func main() {
devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo
devHandler.RPUserMetadataRepo = rpUserMetadataRepo
devHandler.RPUsageQueries = rpUsageQueryRepo
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
@@ -825,6 +826,7 @@ func main() {
dev.Get("/consents", devHandler.ListConsents)
dev.Delete("/consents", devHandler.RevokeConsents)
dev.Get("/audit-logs", devHandler.ListAuditLogs)
dev.Get("/rp-usage/daily", devHandler.GetRPUsageDaily)
// [New] Developer Registration Flow
dev.Post("/developer-request", devHandler.RequestDeveloperAccess)

View File

@@ -39,6 +39,7 @@ type DevHandler struct {
TenantSvc service.TenantService
DeveloperSvc *service.DeveloperService
RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageQueries domain.RPUsageQueryRepository
Auth interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
}
@@ -94,6 +95,13 @@ type devStatsResponse struct {
AuthFailures int64 `json:"auth_failures_24h"`
}
type devRPUsageDailyResponse struct {
Items []domain.RPUsageDailyMetric `json:"items"`
Days int `json:"days"`
Period string `json:"period"`
TenantID string `json:"tenantId,omitempty"`
}
type clientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -369,16 +377,12 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
if role == domain.RoleSuperAdmin {
return true
}
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
if canAccessClientByLegacyScope(profile, summary) {
return true
}
clientTenantID := resolveClientTenantID(summary)
if role != domain.RoleUser && clientTenantID != "" {
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
return true
}
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
return true
}
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
@@ -512,6 +516,26 @@ func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[strin
return dst
}
func shouldScopeDashboardToExplicitClients(role string) bool {
switch normalizeUserRole(role) {
case domain.RoleRPAdmin, domain.RoleUser:
return true
default:
return false
}
}
func clientIDSetFromSummaries(items []clientSummary) map[string]struct{} {
ids := make(map[string]struct{}, len(items))
for _, item := range items {
id := strings.TrimSpace(item.ID)
if id != "" {
ids[id] = struct{}{}
}
}
return ids
}
func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
if profile == nil {
return false
@@ -1009,6 +1033,70 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
return allowed, nil
}
func (h *DevHandler) listVisibleClientSummaries(
c *fiber.Ctx,
profile *domain.UserProfileResponse,
limit int,
offset int,
) ([]clientSummary, error) {
if profile == nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return nil, fiber.NewError(fiber.StatusForbidden, "forbidden")
}
userTenantID := tenantIDFromProfile(profile)
isSuperAdmin := role == domain.RoleSuperAdmin
allowedClientIDs := managedClientIDsFromProfile(profile)
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
slog.Error("Failed to check app manager permission", "error", err)
}
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil {
return nil, err
}
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
summary := h.mapClientSummary(client)
canViewByPermit := h.canViewClientByPermit(c, profile, summary)
if summary.Type == "private" && !isAppManager && !canViewByPermit {
continue
}
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID && !canViewByPermit {
continue
}
}
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
if _, ok := allowedClientIDs[summary.ID]; !ok && !canViewByPermit {
continue
}
}
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
continue
}
items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary))
}
return items, nil
}
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
authHeader = strings.TrimSpace(authHeader)
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
@@ -1133,72 +1221,22 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
}
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
userTenantID := tenantIDFromProfile(profile)
isSuperAdmin := role == domain.RoleSuperAdmin
allowedClientIDs := managedClientIDsFromProfile(profile)
isAppManager, err := h.checkAppManagerPermission(c)
items, err := h.listVisibleClientSummaries(c, profile, limit, offset)
if err != nil {
slog.Error("Failed to check app manager permission", "error", err)
}
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "clients not found")
}
status := fiber.StatusInternalServerError
errMsg := err.Error()
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
status = fiberErr.Code
errMsg = fiberErr.Message
} else if errors.Is(err, service.ErrHydraNotFound) {
status = fiber.StatusNotFound
errMsg = "clients not found"
} else if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
status = fiber.StatusServiceUnavailable
errMsg = "Hydra service is unavailable. Please check if Ory Hydra is running."
}
return errorJSON(c, fiber.StatusInternalServerError, errMsg)
}
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
summary := h.mapClientSummary(client)
// 1. [Security] Filter out 'private' clients if user is not an AppManager
canViewByPermit := h.canViewClientByPermit(c, profile, summary)
if summary.Type == "private" && !isAppManager && !canViewByPermit {
continue
}
// 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID && !canViewByPermit {
continue
}
}
// 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
if _, ok := allowedClientIDs[summary.ID]; !ok {
if !canViewByPermit {
continue
}
}
}
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
continue
}
items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary))
return errorJSON(c, status, errMsg)
}
return c.JSON(clientListResponse{
@@ -2547,59 +2585,42 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
func (h *DevHandler) GetStats(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
// [Security] Check permission
allowed, err := h.checkAppManagerPermission(c)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
if !allowed {
role := normalizeUserRole(profile.Role)
if !isDevConsoleViewerRole(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
userTenantID := ""
isSuperAdmin := false
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients")
}
// 1. Total Clients (Tenant Scoped)
// Hydra doesn't support tenant filtering natively, so we list and filter.
// For stats, we might want to fetch a larger batch or use a cached count.
clients, err := h.Hydra.ListClients(c.Context(), 500, 0)
var totalClients int64
if err == nil {
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
if isSuperAdmin {
totalClients++
continue
}
if client.Metadata != nil {
if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID {
totalClients++
}
}
}
}
userTenantID := tenantIDFromProfile(profile)
totalClients := int64(len(visibleClients))
visibleClientIDs := clientIDSetFromSummaries(visibleClients)
// 2. Auth Failures (24h)
var authFailures int64
if h.AuditRepo != nil {
since := time.Now().Add(-24 * time.Hour)
authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), since, userTenantID)
}
// 3. Active Sessions (1h)
var activeSessions int64
if h.AuditRepo != nil {
since := time.Now().Add(-1 * time.Hour)
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID)
failureSince := time.Now().Add(-24 * time.Hour)
sessionSince := time.Now().Add(-1 * time.Hour)
if shouldScopeDashboardToExplicitClients(role) {
authFailures, activeSessions, _ = h.countScopedDashboardAuditMetrics(
c,
userTenantID,
visibleClientIDs,
failureSince,
sessionSince,
)
} else {
authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), failureSince, userTenantID)
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), sessionSince, userTenantID)
}
}
return c.JSON(devStatsResponse{
@@ -2609,6 +2630,75 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
})
}
func (h *DevHandler) GetRPUsageDaily(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleViewerRole(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
if h == nil || h.RPUsageQueries == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "rp usage query service unavailable")
}
days := 14
if raw := c.Query("days"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
days = parsed
}
}
period := normalizeRPUsagePeriod(c.Query("period"))
visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients")
}
allowedClientIDs := clientIDSetFromSummaries(visibleClients)
if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 {
return c.JSON(devRPUsageDailyResponse{
Items: []domain.RPUsageDailyMetric{},
Days: days,
Period: period,
})
}
tenantID := ""
if role != domain.RoleSuperAdmin {
tenantID = tenantIDFromProfile(profile)
}
items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{
Days: days,
Period: period,
TenantID: tenantID,
})
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
filtered := make([]domain.RPUsageDailyMetric, 0, len(items))
for _, item := range items {
if role != domain.RoleSuperAdmin {
if _, ok := allowedClientIDs[strings.TrimSpace(item.ClientID)]; !ok {
continue
}
}
filtered = append(filtered, item)
}
return c.JSON(devRPUsageDailyResponse{
Items: filtered,
Days: days,
Period: period,
TenantID: tenantID,
})
}
func generateRandomSecret(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
@@ -3262,6 +3352,74 @@ func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string {
return ""
}
func (h *DevHandler) countScopedDashboardAuditMetrics(
c *fiber.Ctx,
tenantID string,
allowedClientIDs map[string]struct{},
failureSince, sessionSince time.Time,
) (int64, int64, error) {
if h.AuditRepo == nil || len(allowedClientIDs) == 0 {
return 0, 0, nil
}
oldestSince := failureSince
if sessionSince.Before(oldestSince) {
oldestSince = sessionSince
}
var failureCount int64
activeSessions := make(map[string]struct{})
var cursor *domain.AuditCursor
const pageSize = 200
const maxScan = 5000
scanned := 0
for scanned < maxScan {
page, err := h.AuditRepo.FindPage(c.Context(), pageSize, cursor, tenantID)
if err != nil {
return 0, 0, err
}
if len(page) == 0 {
break
}
stop := false
for _, logItem := range page {
scanned++
if logItem.Timestamp.Before(oldestSince) {
stop = true
break
}
details, _ := utils.ParseAuditDetails(logItem.Details)
clientID := strings.TrimSpace(resolveDevAuditClientID(logItem, details))
if _, ok := allowedClientIDs[clientID]; !ok {
continue
}
if strings.EqualFold(logItem.Status, "failure") && !logItem.Timestamp.Before(failureSince) {
failureCount++
}
if strings.EqualFold(logItem.Status, "success") && !logItem.Timestamp.Before(sessionSince) {
sessionID := strings.TrimSpace(logItem.SessionID)
if sessionID != "" {
activeSessions[sessionID] = struct{}{}
}
}
}
if stop || len(page) < pageSize {
break
}
last := page[len(page)-1]
cursor = &domain.AuditCursor{Timestamp: last.Timestamp, EventID: last.EventID}
}
return failureCount, int64(len(activeSessions)), nil
}
// ListMyTenants returns the list of tenants the current user manages or belongs to.
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
profile, err := h.Auth.GetEnrichedProfile(c)

View File

@@ -942,7 +942,6 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) {
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil)
h := &DevHandler{
@@ -1368,6 +1367,22 @@ func TestGetStats_Success(t *testing.T) {
}
mockKeto := new(devMockKetoService)
mockKeto.On(
"CheckPermission",
mock.Anything,
"User:u1",
"RelyingParty",
mock.Anything,
"view",
).Return(false, nil).Maybe()
mockKeto.On(
"ListRelations",
mock.Anything,
"RelyingParty",
mock.Anything,
mock.Anything,
mock.Anything,
).Return([]service.RelationTuple{}, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
@@ -1400,6 +1415,164 @@ func TestGetStats_Success(t *testing.T) {
mockKeto.AssertNotCalled(t, "CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all")
}
func TestGetStats_UserScopesAuditMetricsToVisibleClients(t *testing.T) {
now := time.Now()
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "client-owned", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
{"client_id": "client-other", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
auditRepo := &mockAuditRepo{
logs: []domain.AuditLog{
{
EventID: "evt-1",
Timestamp: now.Add(-15 * time.Minute),
SessionID: "sess-owned",
Status: "success",
EventType: "GET /api/v1/dev/clients/client-owned",
Details: `{"client_id":"client-owned","tenant_id":"tenant-a"}`,
},
{
EventID: "evt-2",
Timestamp: now.Add(-20 * time.Minute),
Status: "failure",
EventType: "GET /api/v1/dev/clients/client-owned",
Details: `{"client_id":"client-owned","tenant_id":"tenant-a"}`,
},
{
EventID: "evt-3",
Timestamp: now.Add(-10 * time.Minute),
SessionID: "sess-other",
Status: "success",
EventType: "GET /api/v1/dev/clients/client-other",
Details: `{"client_id":"client-other","tenant_id":"tenant-a"}`,
},
{
EventID: "evt-4",
Timestamp: now.Add(-30 * time.Minute),
Status: "failure",
EventType: "GET /api/v1/dev/clients/client-other",
Details: `{"client_id":"client-other","tenant_id":"tenant-a"}`,
},
},
}
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil)
mockKeto.On(
"ListRelations",
mock.Anything,
"RelyingParty",
mock.Anything,
mock.Anything,
mock.Anything,
).Return([]service.RelationTuple{}, nil).Maybe()
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
AuditRepo: auditRepo,
Keto: mockKeto,
}
app := fiber.New()
tenantID := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
})
app.Get("/api/v1/dev/stats", h.GetStats)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/stats", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res devStatsResponse
_ = json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, int64(1), res.TotalClients)
assert.Equal(t, int64(1), res.AuthFailures)
assert.Equal(t, int64(1), res.ActiveSessions)
mockKeto.AssertExpectations(t)
}
func TestGetRPUsageDaily_UserScopesItemsToVisibleClients(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/clients" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{"client_id": "client-owned", "client_name": "Owned App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
{"client_id": "client-other", "client_name": "Other App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}},
}), nil
}
return httpJSONAny(r, http.StatusNotFound, nil), nil
})
mockKeto := new(devMockKetoService)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-owned", "view").Return(true, nil)
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-other", "view").Return(false, nil)
mockKeto.On(
"ListRelations",
mock.Anything,
"RelyingParty",
mock.Anything,
mock.Anything,
mock.Anything,
).Return([]service.RelationTuple{}, nil).Maybe()
usageRepo := &fakeRPUsageQueryRepo{
items: []domain.RPUsageDailyMetric{
{Date: "2026-05-12", TenantID: "tenant-a", ClientID: "client-owned", ClientName: "Owned App", LoginRequests: 3},
{Date: "2026-05-12", TenantID: "tenant-a", ClientID: "client-other", ClientName: "Other App", LoginRequests: 9},
},
}
h := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: transport},
},
Keto: mockKeto,
RPUsageQueries: usageRepo,
}
app := fiber.New()
tenantID := "tenant-a"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &tenantID,
})
return c.Next()
})
app.Get("/api/v1/dev/rp-usage/daily", h.GetRPUsageDaily)
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/rp-usage/daily?days=14&period=day", nil)
resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res devRPUsageDailyResponse
_ = json.NewDecoder(resp.Body).Decode(&res)
if assert.Len(t, res.Items, 1) {
assert.Equal(t, "client-owned", res.Items[0].ClientID)
}
assert.Equal(t, "tenant-a", usageRepo.query.TenantID)
mockKeto.AssertExpectations(t)
}
func TestDevHandler_NoAuditNoAction(t *testing.T) {
h := &DevHandler{
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},

1
common/.gitkeep Normal file
View File

@@ -0,0 +1 @@

63
common/core/auth/index.ts Normal file
View 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
View 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
View 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
View 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[];
}

View File

@@ -0,0 +1,7 @@
export const queryClientDefaultOptions = {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
} as const;

View 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);
}

View 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
View 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
View 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
View 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 = "성공"

View 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
View 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
View 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
View 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
View 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
View 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
View 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";

View File

@@ -1,11 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
defaultOptions: queryClientDefaultOptions,
});

View File

@@ -1,8 +1,4 @@
import {
Navigate,
type RouteObject,
createBrowserRouter,
} from "react-router-dom";
import { type RouteObject, createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
@@ -13,6 +9,7 @@ import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
import ClientsPage from "../features/clients/ClientsPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
import ProfilePage from "../features/profile/ProfilePage";
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
@@ -33,7 +30,7 @@ export const devFrontRoutes: RouteObject[] = [
{
element: <AppLayout />,
children: [
{ index: true, element: <Navigate to="/clients" replace /> },
{ index: true, element: <DashboardPage /> },
{ path: "clients", element: <ClientsPage /> },
{ path: "clients/new", element: <ClientGeneralPage /> },
{ path: "clients/:id", element: <ClientDetailsPage /> },

View File

@@ -3,6 +3,7 @@ import {
BadgeCheck,
ChevronDown,
ClipboardCheck,
LayoutDashboard,
LogOut,
Moon,
NotebookTabs,
@@ -20,10 +21,25 @@ import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import {
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
const navItems = [
{
labelKey: "ui.dev.nav.overview",
labelFallback: "Overview",
to: "/",
icon: LayoutDashboard,
},
{
labelKey: "ui.dev.nav.clients",
labelFallback: "Clients",
@@ -52,15 +68,11 @@ function AppLayout() {
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
return stored !== "false";
});
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
const [nowMs, setNowMs] = useState(() => Date.now());
const hasAccessToken = Boolean(auth.user?.access_token);
@@ -82,14 +94,7 @@ function AppLayout() {
};
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
@@ -265,84 +270,41 @@ function AppLayout() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileName =
profile?.name?.trim() ||
auth.user?.profile?.name?.toString().trim() ||
auth.user?.profile?.preferred_username?.toString().trim() ||
auth.user?.profile?.nickname?.toString().trim() ||
t("ui.dev.profile.unknown_name", "Unknown User");
const profileEmail =
profile?.email?.trim() ||
auth.user?.profile?.email?.toString().trim() ||
t("ui.dev.profile.unknown_email", "unknown@example.com");
const profileInitial = profileName.charAt(0).toUpperCase();
const profileSummary = buildShellProfileSummary({
profileName:
profile?.name ||
auth.user?.profile?.name?.toString() ||
auth.user?.profile?.preferred_username?.toString() ||
auth.user?.profile?.nickname?.toString(),
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
});
const currentRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const displayRoleKey = profile?.role || currentRole;
const expiresAtSec = auth.user?.expires_at;
const remainingMs =
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
const remainingTotalSec =
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
const remainingMinutes =
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
const remainingSeconds =
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "Session active");
if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "Unknown");
} else if (remainingMs <= 0) {
sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "Session expired");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
remainingMinutes <= 5
) {
sessionToneClass =
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
sessionText = t(
"ui.dev.session.expiring",
"Expiring soon: {{minutes}}m {{seconds}}s left",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
},
);
} else {
sessionText = t(
"ui.dev.session.remaining",
"Expires in {{minutes}}m {{seconds}}s",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
},
);
}
const sessionStatus = buildShellSessionStatus({
expiresAtSec: auth.user?.expires_at,
nowMs,
t,
});
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
writeShellSessionExpiryEnabled(next);
return next;
});
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className={shellLayoutClasses.root}>
<aside className={shellLayoutClasses.aside}>
<div>
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>
<ShieldHalf size={20} />
</div>
<div>
@@ -354,28 +316,29 @@ function AppLayout() {
</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<div className={shellLayoutClasses.scopeBadge}>
<BadgeCheck size={14} />
{t("ui.dev.scope_badge", "Scoped to /dev")}
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<nav className={shellLayoutClasses.navWrap}>
<div className={shellLayoutClasses.navMeta}>
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
</div>
<div className="flex flex-col gap-1">
<div className={shellLayoutClasses.navList}>
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
shellLayoutClasses.navItemBase,
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
@@ -392,13 +355,13 @@ function AppLayout() {
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
<div className={shellLayoutClasses.sidebarFooterNotice}>
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
<p>
{t(
@@ -410,10 +373,10 @@ function AppLayout() {
</div>
</aside>
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<div className={shellLayoutClasses.content}>
<header className={shellLayoutClasses.header}>
<div className={shellLayoutClasses.headerInner}>
<div className={shellLayoutClasses.headerTitleWrap}>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.dev.header.plane", "Dev Plane")}
</p>
@@ -421,12 +384,12 @@ function AppLayout() {
{t("ui.dev.header.subtitle", "Manage your applications")}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div className={shellLayoutClasses.headerActions}>
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
@@ -437,11 +400,11 @@ function AppLayout() {
{isSessionExpiryEnabled ? (
<span
className={[
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
sessionToneClass,
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionText}
{sessionStatus.text}
</span>
) : null}
<div className="relative" ref={profileMenuRef}>
@@ -456,15 +419,15 @@ function AppLayout() {
"Open account menu",
)}
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profileInitial}
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<ChevronDown
@@ -473,20 +436,17 @@ function AppLayout() {
/>
</button>
{isProfileMenuOpen ? (
<div
role="menu"
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
>
<div role="menu" className={shellLayoutClasses.profileMenu}>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</p>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.profileCard}>
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<div className="flex items-center pt-1">
@@ -499,7 +459,7 @@ function AppLayout() {
</div>
</div>
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
@@ -507,7 +467,7 @@ function AppLayout() {
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
? sessionStatus.text
: t(
"ui.dev.session.disabled",
"Session expiry disabled",
@@ -563,7 +523,7 @@ function AppLayout() {
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<main className={shellLayoutClasses.main}>
<Outlet />
</main>
</div>

View File

@@ -1,39 +1,21 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "text-foreground",
muted: "border-border bg-secondary/60 text-muted-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
info: "border-transparent bg-blue-500 text-white hover:bg-blue-500/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: CommonBadgeVariant;
}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div
className={cn(getCommonBadgeClasses({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
export { Badge };

View File

@@ -1,41 +1,16 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import {
type CommonButtonSize,
type CommonButtonVariant,
getCommonButtonClasses,
} from "../../../../common/ui/button";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: CommonButtonVariant;
size?: CommonButtonSize;
asChild?: boolean;
}
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(getCommonButtonClasses({ variant, size }), className)}
ref={ref}
{...props}
/>
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button };

View File

@@ -1,65 +1,51 @@
import type * as React from "react";
import {
commonCardClass,
commonCardContentClass,
commonCardDescriptionClass,
commonCardFooterClass,
commonCardHeaderClass,
commonCardTitleClass,
} from "../../../../common/ui/card";
import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
className,
)}
{...props}
/>
);
return <div className={cn(commonCardClass, className)} {...props} />;
}
function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
);
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
}
function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
}
function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
}
function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
return <div className={cn(commonCardContentClass, className)} {...props} />;
}
function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
return <div className={cn(commonCardFooterClass, className)} {...props} />;
}
export {

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { commonInputClass } from "../../../../common/ui/input";
import { cn } from "../../lib/utils";
export interface InputProps
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
className={cn(commonInputClass, className)}
ref={ref}
{...props}
/>

View File

@@ -1,9 +1,24 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { BookOpenText, Filter, Plus, Search, X } from "lucide-react";
import { useEffect, useState } from "react";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
BookOpenText,
Filter,
Plus,
Search,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom";
import {
sortItems,
toggleSort,
type SortConfig,
type SortResolverMap,
} from "../../../../common/core/utils";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
import {
Avatar,
@@ -37,6 +52,7 @@ import {
fetchDeveloperRequestStatus,
fetchMyTenants,
requestDeveloperAccess,
type ClientSummary,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
@@ -44,6 +60,8 @@ import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi";
import { ClientLogo } from "./components/ClientLogo";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
@@ -104,19 +122,48 @@ function ClientsPage() {
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [sortConfig, setSortConfig] =
useState<SortConfig<ClientSortKey> | null>(null);
const clients = data?.items || [];
const clientSortResolvers = useMemo<
SortResolverMap<ClientSummary, ClientSortKey>
>(
() => ({
application: (client) => client.name || client.id,
id: (client) => client.id,
type: (client) =>
client.metadata?.headless_login_enabled
? "private-headless"
: client.type,
status: (client) => client.status,
createdAt: (client) =>
client.createdAt ? new Date(client.createdAt) : null,
}),
[],
);
const filteredClients = clients.filter((client) => {
const matchesSearch =
!searchQuery ||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = typeFilter === "all" || client.type === typeFilter;
const matchesStatus =
statusFilter === "all" || client.status === statusFilter;
return matchesSearch && matchesType && matchesStatus;
});
const filteredClients = useMemo(() => {
const nextClients = clients.filter((client) => {
const matchesSearch =
!searchQuery ||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = typeFilter === "all" || client.type === typeFilter;
const matchesStatus =
statusFilter === "all" || client.status === statusFilter;
return matchesSearch && matchesType && matchesStatus;
});
return sortItems(nextClients, sortConfig, clientSortResolvers);
}, [
clientSortResolvers,
clients,
searchQuery,
sortConfig,
statusFilter,
typeFilter,
]);
const totalClients = statsData?.total_clients ?? clients.length;
const activeSessions = statsData?.active_sessions ?? 0;
@@ -179,6 +226,22 @@ function ClientsPage() {
const isLoading = isLoadingClients || isLoadingStats || isLoadingRequest;
const requestSort = (key: ClientSortKey) => {
setSortConfig((current) => toggleSort(current, key));
};
const getSortIcon = (key: ClientSortKey) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp className="ml-1 h-4 w-4" />
) : (
<ArrowDown className="ml-1 h-4 w-4" />
);
};
if (auth.isLoading || !hasAccessToken || isLoading) {
return (
<div className="p-8 text-center">
@@ -389,18 +452,50 @@ function ClientsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.dev.clients.table.application", "애플리케이션")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("application")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.application", "애플리케이션")}
{getSortIcon("application")}
</div>
</TableHead>
<TableHead>
{t("ui.dev.clients.table.client_id", "Client ID")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("id")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.client_id", "Client ID")}
{getSortIcon("id")}
</div>
</TableHead>
<TableHead>{t("ui.dev.clients.table.type", "유형")}</TableHead>
<TableHead>
{t("ui.dev.clients.table.status", "상태")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("type")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.type", "유형")}
{getSortIcon("type")}
</div>
</TableHead>
<TableHead>
{t("ui.dev.clients.table.created_at", "생성일")}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("status")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.status", "상태")}
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("createdAt")}
>
<div className="flex items-center">
{t("ui.dev.clients.table.created_at", "생성일")}
{getSortIcon("createdAt")}
</div>
</TableHead>
<TableHead className="text-right">
{t("ui.dev.clients.table.actions", "액션")}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
@import "../../common/theme/base.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -24,37 +26,7 @@
--input: 215 25% 24%;
--ring: 209 79% 52%;
--radius: 0.75rem;
}
.light {
--background: 0 0% 98%;
--foreground: 223 25% 12%;
--card: 0 0% 100%;
--card-foreground: 223 25% 12%;
--popover: 0 0% 100%;
--popover-foreground: 223 25% 12%;
--primary: 209 79% 52%;
--primary-foreground: 0 0% 100%;
--secondary: 220 17% 94%;
--secondary-foreground: 223 25% 20%;
--muted: 223 15% 45%;
--muted-foreground: 223 15% 45%;
--accent: 40 96% 62%;
--accent-foreground: 223 25% 12%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 17% 90%;
--input: 220 17% 90%;
--ring: 209 79% 52%;
}
* {
@apply border-border;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
--app-background-image: radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%
@@ -70,14 +42,4 @@
transparent 30%
);
}
a {
@apply text-inherit no-underline;
}
}
@layer components {
.glass-panel {
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
}
}

View File

@@ -1,32 +1,23 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
import {
buildDevFrontAuthRedirectUris,
resolveDevFrontPublicOrigin,
} from "./authConfig";
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
} from "../../../common/core/auth";
import { resolveDevFrontPublicOrigin } from "./authConfig";
const devFrontPublicOrigin = resolveDevFrontPublicOrigin(
import.meta.env.VITE_DEVFRONT_PUBLIC_URL,
window.location.origin,
);
const devFrontRedirectUris =
buildDevFrontAuthRedirectUris(devFrontPublicOrigin);
export const oidcConfig: AuthProviderProps = {
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
redirect_uri: devFrontRedirectUris.redirectUri,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: devFrontRedirectUris.postLogoutRedirectUri,
popup_redirect_uri: devFrontRedirectUris.popupRedirectUri,
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
origin: devFrontPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
};
export const userManager = new UserManager({
...oidcConfig,
authority: oidcConfig.authority || "",
client_id: oidcConfig.client_id || "",
redirect_uri: oidcConfig.redirect_uri || "",
});
export const userManager = new UserManager(
buildCommonUserManagerSettings(oidcConfig),
);

View File

@@ -53,6 +53,27 @@ export type DevStats = {
auth_failures_24h: number;
};
export type RPUsageDailyMetric = {
date: string;
tenantId: string;
tenantType: string;
tenantName?: string;
clientId: string;
clientName: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
export type RPUsagePeriod = "day" | "week" | "month";
export type RPUsageDailyResponse = {
items: RPUsageDailyMetric[];
days: number;
period: RPUsagePeriod;
tenantId?: string;
};
export type DevAuditLog = {
event_id: string;
timestamp: string;
@@ -214,6 +235,22 @@ export async function fetchDevStats() {
return data;
}
export async function fetchDevRPUsageDaily({
days = 14,
period = "day",
}: {
days?: number;
period?: RPUsagePeriod;
} = {}) {
const { data } = await apiClient.get<RPUsageDailyResponse>(
"/dev/rp-usage/daily",
{
params: { days, period },
},
);
return data;
}
export async function fetchTenants(
limit = 1000,
offset = 0,

View File

@@ -1,149 +1,16 @@
const LOCALE_STORAGE_KEY = "locale";
const DEFAULT_LOCALE = "ko";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
type TomlValue = string | TomlObject;
interface TomlObject {
[key: string]: TomlValue;
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function parseToml(raw: string): TomlObject {
const lines = raw.split(/\r?\n/);
const root: TomlObject = {};
let currentPath: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const sectionName = line.slice(1, -1).trim();
currentPath = sectionName
? sectionName
.split(".")
.map((part) => part.trim())
.filter(Boolean)
: [];
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = line.slice(0, eqIndex).trim();
const valueRaw = line.slice(eqIndex + 1).trim();
if (!key) {
continue;
}
let value = valueRaw;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
let cursor: TomlObject = root;
for (const section of currentPath) {
if (!cursor[section] || typeof cursor[section] === "string") {
cursor[section] = {};
}
cursor = cursor[section] as TomlObject;
}
cursor[key] = value;
}
return root;
}
function getValue(target: TomlObject, key: string): string | undefined {
const parts = key.split(".");
let cursor: TomlValue = target;
for (const part of parts) {
if (typeof cursor !== "object" || cursor === null) {
return undefined;
}
cursor = (cursor as TomlObject)[part];
if (cursor === undefined) {
return undefined;
}
}
return typeof cursor === "string" ? cursor : undefined;
}
function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale && isSupportedLocale(pathLocale)) {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
if (browserLang.startsWith("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
import { createTomlTranslator } from "../../../common/core/i18n";
// eslint-disable-next-line import/no-unresolved
import commonEnRaw from "../../../common/locales/en.toml?raw";
// eslint-disable-next-line import/no-unresolved
import commonKoRaw from "../../../common/locales/ko.toml?raw";
// eslint-disable-next-line import/no-unresolved
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../locales/ko.toml?raw";
const translations: Record<Locale, TomlObject> = {
ko: parseToml(koRaw),
en: parseToml(enRaw),
};
function formatTemplate(
template: string,
vars?: Record<string, string | number>,
): string {
const normalizedTemplate = template.replace(/\\n/g, "\n");
if (!vars) {
return normalizedTemplate;
}
return normalizedTemplate.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
export function t(
key: string,
fallback?: string,
vars?: Record<string, string | number>,
): string {
const locale = detectLocale();
const value = getValue(translations[locale], key);
if (value && value.length > 0) {
return formatTemplate(value, vars);
}
return formatTemplate(fallback ?? key, vars);
}
export const t = createTomlTranslator({
ko: [commonKoRaw, koRaw],
en: [commonEnRaw, enRaw],
});

View File

@@ -1,76 +1,6 @@
export const SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
type SlidingSessionRenewDecisionParams = {
expiresAtSec?: number | null;
nowMs: number;
isEnabled: boolean;
isAuthenticated: boolean;
isLoading: boolean;
isRenewInFlight: boolean;
lastAttemptAtMs: number;
thresholdMs?: number;
throttleMs?: number;
};
export function shouldAttemptSlidingSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
}
export function shouldAttemptUnlimitedSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
}
export {
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../../common/core/session";

View File

@@ -1,6 +1,8 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { mergeClassNames } from "../../../common/core/utils";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return mergeClassNames(twMerge, [clsx(inputs)]);
}

View File

@@ -300,15 +300,6 @@ no_custom = "No custom fields defined for this tenant."
[msg.admin.users.list.registry]
count = "Count"
[msg.common]
error = "Error"
loading = "Loading..."
no_description = "No Description."
parsing = "Parsing data..."
requesting = "Requesting..."
saving = "Saving..."
unknown_error = "unknown error"
[msg.dev]
logout_confirm = "Are you sure you want to log out?"
@@ -509,6 +500,7 @@ openid = "Openid"
profile = "Profile"
[msg.dev.dashboard]
description = "View connected application composition and authentication operations metrics in one place."
[msg.dev.dashboard.hero]
body = "Body"
@@ -516,6 +508,29 @@ title_emphasis = "Title Emphasis"
title_prefix = "Title Prefix"
title_suffix = "Title Suffix"
[msg.dev.dashboard.distribution]
description = "Quickly review application types and headless login usage."
[msg.dev.dashboard.chart]
empty = "No RP usage aggregates to display."
filter_description = "View the chart for all applications or only the ones you select."
forbidden = "Your current account does not have permission to view RP usage statistics."
server_error = "A server error occurred while loading RP usage statistics."
service_unavailable = "The RP usage aggregation service is not ready yet."
unavailable = "RP usage statistics API is unavailable. The chart will appear here once aggregate data is ready."
unavailable_with_reason = "RP usage statistics API is unavailable. {{reason}}"
[msg.dev.dashboard.quick_links]
audit = "Review RP configuration changes and operational history."
clients = "Browse registered RPs and manage their status and type."
description = "Jump directly to key operational screens."
developer_request = "Review developer access requests or submit a new one."
new_client = "Configure redirect URIs, grant types, and authentication methods."
[msg.dev.dashboard.recent]
empty = "Review the relying parties this account can access."
none = "No connected applications to display."
[msg.dev.dashboard.notice]
consent_audit = "Consent Audit"
dev_scope = "Dev Scope"
@@ -1244,75 +1259,6 @@ name = "Name"
role = "Role"
[ui.common]
add = "Add"
all = "All"
admin_only = "Admin Only"
assign = "Assign"
back = "Back"
back_to_login = "Back to login"
cancel = "Cancel"
change_file = "Change File"
clear_search = "Clear Search"
close = "Close"
collapse = "Collapse"
confirm = "Confirm"
copy = "Copy"
create = "Create"
delete = "Delete"
details = "Details"
disabled = "Disabled"
edit = "Edit"
enabled = "Enabled"
export = "Export"
fail = "Fail"
go_home = "Go Home"
view = "View"
hyphen = "-"
manage = "Manage"
na = "N/A"
never = "Never"
next = "Next"
none = "None"
page_of = "Page {{page}} of {{total}}"
prev = "Prev"
previous = "Previous"
qr = "QR"
reset = "Reset"
read_only = "Read Only"
refresh = "Refresh"
remove = "Remove"
resend = "Resend"
retry = "Retry"
save = "Save"
search = "Search"
select = "Select"
select_file = "Select File"
select_placeholder = "Select Placeholder"
show_more = "Show More"
language = "Language"
language_ko = "Korean"
language_en = "English"
success = "Success"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "Theme Toggle"
unknown = "Unknown"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "Active"
blocked = "Blocked"
failure = "Failure"
inactive = "Inactive"
ok = "Ok"
pending = "Pending"
success = "Success"
[test]
key = "Test"
@@ -1330,6 +1276,7 @@ audit_logs = "Audit Logs"
clients = "Connected Application"
developer_request = "Developer Access Request"
logout = "Logout"
overview = "Overview"
[ui.dev.audit]
load_more = "Load more"
@@ -1707,12 +1654,32 @@ private_headless = "Server side App (Headless Login)"
[ui.dev.dashboard]
ready_badge = "devfront ready"
title = "Dashboard"
[ui.dev.dashboard.badge]
consent_guard = "Consent guard ready"
oidc = "OIDC operations"
policy_toggle = "Policy toggle enabled"
registry = "RP registry"
rp_synced = "RP registry synced"
[ui.dev.dashboard.distribution]
headless = "Headless Login"
pkce = "PKCE"
private = "Server side App"
title = "Application Distribution"
[ui.dev.dashboard.chart]
aria = "RP request overview"
filter_all = "All"
login_requests = "Login requests"
other_requests = "Other requests"
period_day = "Day"
period_month = "Month"
period_week = "Week"
series = "Login {{login}} / Other {{other}} / Users {{subjects}}"
title = "Login and other requests by application"
[ui.dev.dashboard.next]
subtitle = "Ship the RP controls"
title = "Next actions"
@@ -1730,11 +1697,25 @@ rp_requests = "Rp Requests"
consent = "Consent grants"
rp_status = "RP status"
[ui.dev.dashboard.quick_links]
create_button = "Create RP"
new_client = "New RP"
title = "Quick links"
[ui.dev.dashboard.recent]
title = "My Applications"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"
title = "Stack readiness"
[ui.dev.dashboard.summary]
active_clients = "Active RPs"
active_sessions = "Active sessions"
auth_failures_24h = "24h auth failures"
total_clients = "Total RPs"
[ui.dev.header]
plane = "Dev Plane"
subtitle = "Manage your applications"

View File

@@ -300,15 +300,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다."
[msg.admin.users.list.registry]
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
[msg.common]
error = "오류가 발생했습니다."
loading = "로딩 중..."
no_description = "설명이 없습니다."
parsing = "데이터 파싱 중..."
requesting = "요청 중..."
saving = "저장 중..."
unknown_error = "알 수 없는 오류"
[msg.dev]
logout_confirm = "로그아웃 하시겠습니까?"
@@ -509,6 +500,7 @@ openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근"
[msg.dev.dashboard]
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
[msg.dev.dashboard.hero]
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
@@ -516,6 +508,29 @@ title_emphasis = " 하나의 화면"
title_prefix = "RP 등록 현황과 Consent 상태를"
title_suffix = "에서 관리합니다."
[msg.dev.dashboard.distribution]
description = "애플리케이션 유형과 headless login 사용 현황을 빠르게 확인합니다."
[msg.dev.dashboard.chart]
empty = "표시할 RP 이용 집계가 없습니다."
filter_description = "전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다."
forbidden = "현재 계정에는 RP 이용 통계를 볼 권한이 없습니다."
server_error = "RP 이용 통계 조회 중 서버 오류가 발생했습니다."
service_unavailable = "RP 이용 통계 집계 서비스가 아직 준비되지 않았습니다."
unavailable = "RP 이용 통계 API 응답을 확인할 수 없습니다. 집계 데이터가 준비되면 이 영역에 그래프가 표시됩니다."
unavailable_with_reason = "RP 이용 통계 API 응답을 확인할 수 없습니다. {{reason}}"
[msg.dev.dashboard.quick_links]
audit = "RP 설정 변경과 운영 이력을 확인합니다."
clients = "등록된 RP를 조회하고 상태와 유형을 관리합니다."
description = "주요 운영 화면으로 바로 이동합니다."
developer_request = "개발자 권한 신청 내역을 확인하거나 새 요청을 등록합니다."
new_client = "redirect URI, grant type, 인증 방식을 설정합니다."
[msg.dev.dashboard.recent]
empty = "현재 계정이 접근할 수 있는 RP를 확인합니다."
none = "표시할 연동 앱이 없습니다."
[msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계"
dev_scope = "RP 정책은 dev scope에서만 적용"
@@ -1244,75 +1259,6 @@ name = "이름"
role = "역할"
[ui.common]
add = "추가"
all = "전체"
admin_only = "관리자 전용"
assign = "할당"
back = "돌아가기"
back_to_login = "로그인으로 돌아가기"
cancel = "취소"
change_file = "파일 변경"
clear_search = "검색 초기화"
close = "닫기"
collapse = "접기"
confirm = "확인"
copy = "복사"
create = "생성"
delete = "삭제"
details = "상세정보"
disabled = "사용 안 함"
edit = "편집"
enabled = "사용"
export = "내보내기"
fail = "실패"
go_home = "홈으로"
view = "보기"
hyphen = "-"
manage = "관리"
na = "N/A"
never = "Never"
next = "다음"
none = "없음"
page_of = "Page {{page}} of {{total}}"
prev = "이전"
previous = "이전"
qr = "QR"
reset = "초기화"
read_only = "읽기 전용"
refresh = "새로고침"
remove = "제외"
resend = "재발송"
retry = "다시 시도"
save = "저장"
search = "검색"
select = "선택"
select_file = "파일 선택"
select_placeholder = "선택하세요"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
success = "성공"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
unknown = "Unknown"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"
[test]
key = "테스트"
@@ -1330,6 +1276,7 @@ audit_logs = "감사 로그"
clients = "연동 앱"
developer_request = "개발자 권한 신청"
logout = "로그아웃"
overview = "개요"
[ui.dev.audit]
load_more = "더 보기"
@@ -1706,12 +1653,32 @@ private_headless = "Server side App (Headless Login)"
[ui.dev.dashboard]
ready_badge = "devfront ready"
title = "대시보드"
[ui.dev.dashboard.badge]
consent_guard = "Consent guard ready"
oidc = "OIDC 운영"
policy_toggle = "Policy toggle enabled"
registry = "RP registry"
rp_synced = "RP registry synced"
[ui.dev.dashboard.distribution]
headless = "Headless Login"
pkce = "PKCE"
private = "Server side App"
title = "애플리케이션 구성 요약"
[ui.dev.dashboard.chart]
aria = "RP 요청 현황"
filter_all = "전체"
login_requests = "로그인 요청"
other_requests = "기타 요청"
period_day = "일"
period_month = "월"
period_week = "주"
series = "로그인 {{login}} / 기타 {{other}} / 사용자 {{subjects}}"
title = "애플리케이션별 로그인요청/기타 요청 현황"
[ui.dev.dashboard.next]
subtitle = "Ship the RP controls"
title = "Next actions"
@@ -1729,11 +1696,25 @@ rp_requests = "RP 요청 추이"
consent = "Consent grants"
rp_status = "RP status"
[ui.dev.dashboard.quick_links]
create_button = "새 RP 만들기"
new_client = "새 RP 생성"
title = "빠른 이동"
[ui.dev.dashboard.recent]
title = "내 애플리케이션"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"
title = "Stack readiness"
[ui.dev.dashboard.summary]
active_clients = "활성 RP 수"
active_sessions = "활성 세션 수"
auth_failures_24h = "24시간 인증 실패 수"
total_clients = "총 RP 수"
[ui.dev.header]
plane = "Dev Plane"
subtitle = "Manage your applications"

View File

@@ -314,15 +314,6 @@ no_custom = ""
[msg.admin.users.list.registry]
count = ""
[msg.common]
error = ""
loading = ""
no_description = ""
parsing = ""
requesting = ""
saving = ""
unknown_error = ""
[msg.dev]
logout_confirm = ""
@@ -547,6 +538,7 @@ openid = ""
profile = ""
[msg.dev.dashboard]
description = ""
[msg.dev.dashboard.hero]
body = ""
@@ -554,6 +546,29 @@ title_emphasis = ""
title_prefix = ""
title_suffix = ""
[msg.dev.dashboard.distribution]
description = ""
[msg.dev.dashboard.chart]
empty = ""
filter_description = ""
forbidden = ""
server_error = ""
service_unavailable = ""
unavailable = ""
unavailable_with_reason = ""
[msg.dev.dashboard.quick_links]
audit = ""
clients = ""
description = ""
developer_request = ""
new_client = ""
[msg.dev.dashboard.recent]
empty = ""
none = ""
[msg.dev.dashboard.notice]
consent_audit = ""
dev_scope = ""
@@ -1297,75 +1312,6 @@ name = ""
role = ""
[ui.common]
add = ""
all = ""
admin_only = ""
assign = ""
back = ""
back_to_login = ""
cancel = ""
change_file = ""
clear_search = ""
close = ""
collapse = ""
confirm = ""
copy = ""
create = ""
delete = ""
details = ""
disabled = ""
edit = ""
enabled = ""
export = ""
fail = ""
go_home = ""
view = ""
hyphen = ""
manage = ""
na = ""
never = ""
next = ""
none = ""
page_of = ""
prev = ""
previous = ""
qr = ""
reset = ""
read_only = ""
refresh = ""
remove = ""
resend = ""
retry = ""
save = ""
search = ""
select = ""
select_file = ""
select_placeholder = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
success = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
unknown = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.status]
active = ""
blocked = ""
failure = ""
inactive = ""
ok = ""
pending = ""
success = ""
[test]
key = ""
@@ -1381,8 +1327,9 @@ scope_badge = ""
[ui.dev.nav]
audit_logs = ""
clients = ""
logout = ""
developer_request = ""
logout = ""
overview = ""
[ui.dev.welcome]
btn_request = ""
@@ -1763,12 +1710,32 @@ private_headless = ""
[ui.dev.dashboard]
ready_badge = ""
title = ""
[ui.dev.dashboard.badge]
consent_guard = ""
oidc = ""
policy_toggle = ""
registry = ""
rp_synced = ""
[ui.dev.dashboard.distribution]
headless = ""
pkce = ""
private = ""
title = ""
[ui.dev.dashboard.chart]
aria = ""
filter_all = ""
login_requests = ""
other_requests = ""
period_day = ""
period_month = ""
period_week = ""
series = ""
title = ""
[ui.dev.dashboard.next]
subtitle = ""
title = ""
@@ -1786,11 +1753,25 @@ rp_requests = ""
consent = ""
rp_status = ""
[ui.dev.dashboard.quick_links]
create_button = ""
new_client = ""
title = ""
[ui.dev.dashboard.recent]
title = ""
[ui.dev.dashboard.stack]
notes = ""
subtitle = ""
title = ""
[ui.dev.dashboard.summary]
active_clients = ""
active_sessions = ""
auth_failures_24h = ""
total_clients = ""
[ui.dev.header]
plane = ""
subtitle = ""

View File

@@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme";
const config: Config = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
"../common/**/*.{ts,tsx,css}",
],
theme: {
container: {
center: true,

View File

@@ -62,6 +62,7 @@ services:
- "${ADMINFRONT_PORT:-5173}:5173"
volumes:
- ./adminfront:/app
- ./common:/common
- ./locales:/locales
- /app/node_modules
networks:
@@ -82,6 +83,7 @@ services:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:
- ./devfront:/app
- ./common:/common
- ./locales:/locales
- /app/node_modules
networks:
@@ -102,6 +104,7 @@ services:
- "${ORGFRONT_PORT:-5175}:5175"
volumes:
- ./orgfront:/app
- ./common:/common
- ./locales:/locales
- /app/node_modules
networks:

View File

@@ -119,14 +119,18 @@ TOML에서는 `[Section]`을 사용하여 계층을 표현합니다.
./scripts/sync_userfront_locales.sh
```
* 이 단계가 누락되면 루트 SoT와 UserFront 실제 표시 문구가 어긋날 수 있습니다.
4. **React 공통 locale 레이어**:
* React 계열 프런트(`adminfront`, `devfront`, `orgfront`)는 `common/locales/*.toml`을 공통 문구 레이어로 사용합니다.
* 공통 key는 `ui.common.*`, `msg.common.*` 범위에만 둡니다.
* 각 앱의 `src/locales/*.toml`은 앱 전용 문구를 유지하고, 로딩 시 `common locale -> app locale override` 순서로 merge 합니다.
3. **CI 검증 (Verification)**:
* **Level 1: 리소스 동기화 검사 (`template` vs `lang`)**
* `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
* `locales/*.toml`과 `common/locales/*.toml` 각각에 대해 `template.toml`에 있는 모든 키가 `ko.toml`, `en.toml`에 존재하는지 재귀적으로 검사합니다.
* 누락 시 빌드 실패.
* **Level 2: 코드 사용성 검사 (`code` vs `template`)**
* 전체 프론트엔드 소스코드(`src/**/*.{ts,tsx}`, `lib/**/*.dart`)를 스캔하여 번역 함수(`t('key')`, `'key'.tr()`)에 사용된 키를 추출합니다.
* **Missing Key**: 코드에는 있는데 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다.
* **Unused Key**: `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다.
* **Missing Key**: 코드에는 있는데 해당 레이어의 `template.toml`에 없는 키를 검출하여 경고 또는 에러를 발생시킵니다.
* **Unused Key**: `template.toml`에는 있는데 코드 어디에서도 쓰이지 않는 키를 리포트하여 정리할 수 있게 합니다.
#### 5.2.3 React (Admin/Dev) 구현 가이드
* **패키지 설치**:

View File

@@ -553,6 +553,7 @@ openid = "Openid"
profile = "Profile"
[msg.dev.dashboard]
description = "Review RP composition and authentication operations in one place."
[msg.dev.dashboard.hero]
body = "Monitor RP readiness, consent activity, and operational status for the current developer workspace."
@@ -560,6 +561,21 @@ title_emphasis = "Title Emphasis"
title_prefix = "Title Prefix"
title_suffix = "Title Suffix"
[msg.dev.dashboard.chart]
empty = "No RP usage aggregates are available."
filter_description = "View the graph for all apps or only the selected apps."
forbidden = "This account does not have permission to view RP usage statistics."
server_error = "A server error occurred while loading RP usage statistics."
service_unavailable = "The RP usage aggregation service is not ready yet."
unavailable_with_reason = "RP usage statistics are unavailable. {{reason}}"
[msg.dev.dashboard.distribution]
description = "Quickly review application types and headless login usage."
[msg.dev.dashboard.recent]
empty = "Review the RPs this account can access."
none = "No linked applications are available."
[msg.dev.dashboard.notice]
consent_audit = "Consent Audit"
dev_scope = "Dev Scope"
@@ -1141,6 +1157,11 @@ parent_organizations = "Parent Organizations"
parent_unresolved = "Parent needs review"
slug_exists = "slug conflict"
title = "Confirm CSV import"
csv_parents = "CSV import"
parent = "Parent"
parent_companies = "Companies"
parent_company_groups = "Company groups"
parent_organizations = "Organizations"
[ui.admin.tenants.admins]
add_button = "Add Button"
@@ -1316,6 +1337,8 @@ parent = "Parent Tenant (Optional)"
parent_help = "Select a parent tenant if this is a subsidiary or sub-organization."
[ui.admin.tenants.parent]
company_only = "Companies and groups only"
search_placeholder = "Search by name or slug"
local_search_placeholder = "Search tenant name or slug"
pick_tenant = "Pick tenant"
@@ -2177,12 +2200,32 @@ private = "Server side App"
[ui.dev.dashboard]
ready_badge = "devfront ready"
title = "Dashboard"
[ui.dev.dashboard.badge]
consent_guard = "Consent guard ready"
oidc = "OIDC operations"
policy_toggle = "Policy toggle enabled"
registry = "RP registry"
rp_synced = "RP registry synced"
[ui.dev.dashboard.distribution]
headless = "Headless Login"
pkce = "PKCE"
private = "Server side App"
title = "Application Distribution"
[ui.dev.dashboard.chart]
aria = "RP request overview"
filter_all = "All"
login_requests = "Login requests"
other_requests = "Other requests"
period_day = "Day"
period_month = "Month"
period_week = "Week"
series = "Login {{login}} / Other {{other}} / Users {{subjects}}"
title = "Login and other requests by application"
[ui.dev.dashboard.next]
subtitle = "Ship the RP controls"
title = "Next actions"
@@ -2200,11 +2243,25 @@ rp_requests = "Rp Requests"
consent = "Consent grants"
rp_status = "RP status"
[ui.dev.dashboard.quick_links]
create_button = "Create RP"
new_client = "New RP"
title = "Quick links"
[ui.dev.dashboard.recent]
title = "My Applications"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"
title = "Stack readiness"
[ui.dev.dashboard.summary]
active_clients = "Active RPs"
active_sessions = "Active sessions"
auth_failures_24h = "24h auth failures"
total_clients = "Total RPs"
[ui.dev.header]
plane = "Dev Plane"
subtitle = "Manage your applications"

View File

@@ -384,6 +384,11 @@ parent_organizations = "상위 조직"
parent_unresolved = "부모 확인 필요"
slug_exists = "slug 충돌"
title = "CSV 가져오기 확인"
csv_parents = "가져오기 CSV"
parent = "상위"
parent_companies = "회사"
parent_company_groups = "그룹사"
parent_organizations = "조직"
[ui.common.badge]
admin_only = "Admin only"
@@ -1039,6 +1044,7 @@ openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근"
[msg.dev.dashboard]
description = "연동 앱 구성과 인증 운영 지표를 한 곳에서 확인합니다."
[msg.dev.dashboard.hero]
body = "Hydra Admin API와 동기화된 RP 목록, 상태 토글, Consent 회수까지 devfront에서 처리하도록 준비합니다."
@@ -1046,6 +1052,21 @@ title_emphasis = " 하나의 화면"
title_prefix = "RP 등록 현황과 Consent 상태를"
title_suffix = "에서 관리합니다."
[msg.dev.dashboard.chart]
empty = "표시할 RP 이용 집계가 없습니다."
filter_description = "전체 또는 선택한 애플리케이션만 기준으로 그래프를 확인합니다."
forbidden = "현재 계정에는 RP 이용 통계를 볼 권한이 없습니다."
server_error = "RP 이용 통계 조회 중 서버 오류가 발생했습니다."
service_unavailable = "RP 이용 통계 집계 서비스가 아직 준비되지 않았습니다."
unavailable_with_reason = "RP 이용 통계 API 응답을 확인할 수 없습니다. {{reason}}"
[msg.dev.dashboard.distribution]
description = "애플리케이션 유형과 headless login 사용 현황을 빠르게 확인합니다."
[msg.dev.dashboard.recent]
empty = "현재 계정이 접근할 수 있는 RP를 확인합니다."
none = "표시할 연동 앱이 없습니다."
[msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계"
dev_scope = "RP 정책은 dev scope에서만 적용"
@@ -1778,6 +1799,8 @@ parent = "상위 테넌트 (선택)"
parent_help = "가족사 테넌트나 하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요."
[ui.admin.tenants.parent]
company_only = "회사/그룹사만 표시"
search_placeholder = "이름 또는 slug 검색"
local_search_placeholder = "테넌트 이름 또는 슬러그 검색"
pick_tenant = "테넌트 선택"
@@ -2639,12 +2662,32 @@ private = "Server side App"
[ui.dev.dashboard]
ready_badge = "devfront ready"
title = "대시보드"
[ui.dev.dashboard.badge]
consent_guard = "Consent guard ready"
oidc = "OIDC 운영"
policy_toggle = "Policy toggle enabled"
registry = "RP registry"
rp_synced = "RP registry synced"
[ui.dev.dashboard.distribution]
headless = "Headless Login"
pkce = "PKCE"
private = "Server side App"
title = "애플리케이션 구성 요약"
[ui.dev.dashboard.chart]
aria = "RP 요청 현황"
filter_all = "전체"
login_requests = "로그인 요청"
other_requests = "기타 요청"
period_day = "일"
period_month = "월"
period_week = "주"
series = "로그인 {{login}} / 기타 {{other}} / 사용자 {{subjects}}"
title = "애플리케이션별 로그인요청/기타 요청 현황"
[ui.dev.dashboard.next]
subtitle = "Ship the RP controls"
title = "Next actions"
@@ -2662,11 +2705,25 @@ rp_requests = "RP 요청 추이"
consent = "Consent grants"
rp_status = "RP status"
[ui.dev.dashboard.quick_links]
create_button = "새 RP 만들기"
new_client = "새 RP 생성"
title = "빠른 이동"
[ui.dev.dashboard.recent]
title = "내 애플리케이션"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"
title = "Stack readiness"
[ui.dev.dashboard.summary]
active_clients = "활성 RP 수"
active_sessions = "활성 세션 수"
auth_failures_24h = "24시간 인증 실패 수"
total_clients = "총 RP 수"
[ui.dev.header]
plane = "Dev Plane"
subtitle = "Manage your applications"

View File

@@ -248,6 +248,11 @@ parent_organizations = ""
parent_unresolved = ""
slug_exists = ""
title = ""
csv_parents = ""
parent = ""
parent_companies = ""
parent_company_groups = ""
parent_organizations = ""
[ui.common.badge]
admin_only = ""
@@ -903,6 +908,7 @@ openid = ""
profile = ""
[msg.dev.dashboard]
description = ""
[msg.dev.dashboard.hero]
body = ""
@@ -910,6 +916,21 @@ title_emphasis = ""
title_prefix = ""
title_suffix = ""
[msg.dev.dashboard.chart]
empty = ""
filter_description = ""
forbidden = ""
server_error = ""
service_unavailable = ""
unavailable_with_reason = ""
[msg.dev.dashboard.distribution]
description = ""
[msg.dev.dashboard.recent]
empty = ""
none = ""
[msg.dev.dashboard.notice]
consent_audit = ""
dev_scope = ""
@@ -1643,6 +1664,8 @@ parent = ""
parent_help = ""
[ui.admin.tenants.parent]
company_only = ""
search_placeholder = ""
local_search_placeholder = ""
pick_tenant = ""
@@ -2518,12 +2541,32 @@ private = ""
[ui.dev.dashboard]
ready_badge = ""
title = ""
[ui.dev.dashboard.badge]
consent_guard = ""
oidc = ""
policy_toggle = ""
registry = ""
rp_synced = ""
[ui.dev.dashboard.distribution]
headless = ""
pkce = ""
private = ""
title = ""
[ui.dev.dashboard.chart]
aria = ""
filter_all = ""
login_requests = ""
other_requests = ""
period_day = ""
period_month = ""
period_week = ""
series = ""
title = ""
[ui.dev.dashboard.next]
subtitle = ""
title = ""
@@ -2541,11 +2584,25 @@ rp_requests = ""
consent = ""
rp_status = ""
[ui.dev.dashboard.quick_links]
create_button = ""
new_client = ""
title = ""
[ui.dev.dashboard.recent]
title = ""
[ui.dev.dashboard.stack]
notes = ""
subtitle = ""
title = ""
[ui.dev.dashboard.summary]
active_clients = ""
active_sessions = ""
auth_failures_24h = ""
total_clients = ""
[ui.dev.header]
plane = ""
subtitle = ""

View File

@@ -1,11 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
defaultOptions: queryClientDefaultOptions,
});

View File

@@ -19,6 +19,15 @@ import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import {
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
@@ -45,15 +54,11 @@ function AppLayout() {
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
return stored !== "false";
});
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
const [nowMs, setNowMs] = useState(() => Date.now());
const hasAccessToken = Boolean(auth.user?.access_token);
@@ -71,14 +76,7 @@ function AppLayout() {
};
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
@@ -254,17 +252,16 @@ function AppLayout() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileName =
profile?.name?.trim() ||
auth.user?.profile?.name?.toString().trim() ||
auth.user?.profile?.preferred_username?.toString().trim() ||
auth.user?.profile?.nickname?.toString().trim() ||
t("ui.dev.profile.unknown_name", "Unknown User");
const profileEmail =
profile?.email?.trim() ||
auth.user?.profile?.email?.toString().trim() ||
t("ui.dev.profile.unknown_email", "unknown@example.com");
const profileInitial = profileName.charAt(0).toUpperCase();
const profileSummary = buildShellProfileSummary({
profileName:
profile?.name ||
auth.user?.profile?.name?.toString() ||
auth.user?.profile?.preferred_username?.toString() ||
auth.user?.profile?.nickname?.toString(),
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
});
const currentRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
@@ -275,68 +272,27 @@ function AppLayout() {
"tenant_admin",
"rp_admin",
].includes(currentRole);
const expiresAtSec = auth.user?.expires_at;
const remainingMs =
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
const remainingTotalSec =
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
const remainingMinutes =
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
const remainingSeconds =
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "세션 활성");
if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "알 수 없음");
} else if (remainingMs <= 0) {
sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "세션 만료");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
remainingMinutes <= 5
) {
sessionToneClass =
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
sessionText = t(
"ui.dev.session.expiring",
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
},
);
} else {
sessionText = t(
"ui.dev.session.remaining",
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
},
);
}
const sessionStatus = buildShellSessionStatus({
expiresAtSec: auth.user?.expires_at,
nowMs,
t,
});
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
writeShellSessionExpiryEnabled(next);
return next;
});
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="flex flex-col justify-between border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className={shellLayoutClasses.root}>
<aside className={shellLayoutClasses.aside}>
<div>
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>
<ShieldHalf size={20} />
</div>
<div>
@@ -348,18 +304,18 @@ function AppLayout() {
</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<div className={shellLayoutClasses.scopeBadge}>
<BadgeCheck size={14} />
{t("ui.dev.scope_badge", "Scoped to /dev")}
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<nav className={shellLayoutClasses.navWrap}>
<div className={shellLayoutClasses.navMeta}>
<span className="rounded-full border border-border px-3 py-1">
{t("ui.dev.env_badge", "Env: dev")}
</span>
</div>
<div className="flex flex-col gap-1">
<div className={shellLayoutClasses.navList}>
{isDevConsoleAllowed &&
navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
@@ -367,10 +323,10 @@ function AppLayout() {
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
shellLayoutClasses.navItemBase,
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
@@ -387,13 +343,13 @@ function AppLayout() {
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
<div className={shellLayoutClasses.sidebarFooterNotice}>
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
<p>
{t(
@@ -405,10 +361,10 @@ function AppLayout() {
</div>
</aside>
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<div className={shellLayoutClasses.content}>
<header className={shellLayoutClasses.header}>
<div className={shellLayoutClasses.headerInner}>
<div className={shellLayoutClasses.headerTitleWrap}>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.dev.header.plane", "Dev Plane")}
</p>
@@ -416,12 +372,12 @@ function AppLayout() {
{t("ui.dev.header.subtitle", "Manage your applications")}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div className={shellLayoutClasses.headerActions}>
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "테마 전환")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
@@ -432,11 +388,11 @@ function AppLayout() {
{isSessionExpiryEnabled ? (
<span
className={[
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
sessionToneClass,
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionText}
{sessionStatus.text}
</span>
) : null}
<div className="relative" ref={profileMenuRef}>
@@ -448,15 +404,15 @@ function AppLayout() {
aria-expanded={isProfileMenuOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profileInitial}
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<ChevronDown
@@ -465,20 +421,17 @@ function AppLayout() {
/>
</button>
{isProfileMenuOpen ? (
<div
role="menu"
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
>
<div role="menu" className={shellLayoutClasses.profileMenu}>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</p>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.profileCard}>
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<div className="flex items-center pt-1">
@@ -491,7 +444,7 @@ function AppLayout() {
</div>
</div>
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
@@ -499,7 +452,7 @@ function AppLayout() {
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
? sessionStatus.text
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
@@ -555,7 +508,7 @@ function AppLayout() {
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<main className={shellLayoutClasses.main}>
<Outlet />
</main>
</div>

View File

@@ -1,39 +1,21 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "text-foreground",
muted: "border-border bg-secondary/60 text-muted-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
info: "border-transparent bg-blue-500 text-white hover:bg-blue-500/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: CommonBadgeVariant;
}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div
className={cn(getCommonBadgeClasses({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
export { Badge };

View File

@@ -1,41 +1,16 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import {
type CommonButtonSize,
type CommonButtonVariant,
getCommonButtonClasses,
} from "../../../../common/ui/button";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: CommonButtonVariant;
size?: CommonButtonSize;
asChild?: boolean;
}
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(getCommonButtonClasses({ variant, size }), className)}
ref={ref}
{...props}
/>
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button };

View File

@@ -1,65 +1,51 @@
import type * as React from "react";
import {
commonCardClass,
commonCardContentClass,
commonCardDescriptionClass,
commonCardFooterClass,
commonCardHeaderClass,
commonCardTitleClass,
} from "../../../../common/ui/card";
import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
className,
)}
{...props}
/>
);
return <div className={cn(commonCardClass, className)} {...props} />;
}
function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
);
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
}
function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
}
function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
}
function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
return <div className={cn(commonCardContentClass, className)} {...props} />;
}
function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
return <div className={cn(commonCardFooterClass, className)} {...props} />;
}
export {

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { commonInputClass } from "../../../../common/ui/input";
import { cn } from "../../lib/utils";
export interface InputProps
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
className={cn(commonInputClass, className)}
ref={ref}
{...props}
/>

View File

@@ -1,3 +1,5 @@
@import "../../common/theme/base.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -24,72 +26,10 @@
--input: 220 17% 90%;
--ring: 209 79% 52%;
--radius: 0.75rem;
}
.light {
--background: 0 0% 98%;
--foreground: 223 25% 12%;
--card: 0 0% 100%;
--card-foreground: 223 25% 12%;
--popover: 0 0% 100%;
--popover-foreground: 223 25% 12%;
--primary: 209 79% 52%;
--primary-foreground: 0 0% 100%;
--secondary: 220 17% 94%;
--secondary-foreground: 223 25% 20%;
--muted: 223 15% 45%;
--muted-foreground: 223 15% 45%;
--accent: 40 96% 62%;
--accent-foreground: 223 25% 12%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 17% 90%;
--input: 220 17% 90%;
--ring: 209 79% 52%;
}
.dark {
--background: 210 25% 6%;
--foreground: 210 35% 96%;
--card: 215 32% 9%;
--card-foreground: 210 35% 96%;
--popover: 215 32% 9%;
--popover-foreground: 210 35% 96%;
--primary: 209 79% 52%;
--primary-foreground: 210 35% 96%;
--secondary: 215 25% 16%;
--secondary-foreground: 210 35% 96%;
--muted: 215 15% 65%;
--muted-foreground: 215 15% 65%;
--accent: 42 95% 57%;
--accent-foreground: 215 25% 10%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 35% 96%;
--border: 215 25% 24%;
--input: 215 25% 24%;
--ring: 209 79% 52%;
}
* {
@apply border-border;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: linear-gradient(
--app-background-image: linear-gradient(
180deg,
hsl(var(--background)) 0%,
hsl(var(--secondary) / 0.35) 100%
);
}
a {
@apply text-inherit no-underline;
}
}
@layer components {
.glass-panel {
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
}
}

View File

@@ -1,33 +1,24 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
import {
buildOrgFrontAuthRedirectUris,
resolveOrgFrontPublicOrigin,
} from "./authConfig";
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
} from "../../../common/core/auth";
import { resolveOrgFrontPublicOrigin } from "./authConfig";
const orgFrontPublicOrigin = resolveOrgFrontPublicOrigin(
import.meta.env.VITE_ORGFRONT_PUBLIC_URL,
window.location.origin,
);
const orgFrontRedirectUris =
buildOrgFrontAuthRedirectUris(orgFrontPublicOrigin);
export const oidcConfig: AuthProviderProps = {
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
authority:
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
redirect_uri: orgFrontRedirectUris.redirectUri,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: orgFrontRedirectUris.postLogoutRedirectUri,
popup_redirect_uri: orgFrontRedirectUris.popupRedirectUri,
import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc",
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "orgfront",
origin: orgFrontPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
};
export const userManager = new UserManager({
...oidcConfig,
authority: oidcConfig.authority || "",
client_id: oidcConfig.client_id || "",
redirect_uri: oidcConfig.redirect_uri || "",
});
export const userManager = new UserManager(
buildCommonUserManagerSettings(oidcConfig),
);

View File

@@ -1,148 +1,16 @@
const LOCALE_STORAGE_KEY = "locale";
const DEFAULT_LOCALE = "ko";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
type TomlValue = string | TomlObject;
interface TomlObject {
[key: string]: TomlValue;
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function parseToml(raw: string): TomlObject {
const lines = raw.split(/\r?\n/);
const root: TomlObject = {};
let currentPath: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const sectionName = line.slice(1, -1).trim();
currentPath = sectionName
? sectionName
.split(".")
.map((part) => part.trim())
.filter(Boolean)
: [];
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = line.slice(0, eqIndex).trim();
const valueRaw = line.slice(eqIndex + 1).trim();
if (!key) {
continue;
}
let value = valueRaw;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
let cursor: TomlObject = root;
for (const section of currentPath) {
if (!cursor[section] || typeof cursor[section] === "string") {
cursor[section] = {};
}
cursor = cursor[section] as TomlObject;
}
cursor[key] = value;
}
return root;
}
function getValue(target: TomlObject, key: string): string | undefined {
const parts = key.split(".");
let cursor: TomlValue = target;
for (const part of parts) {
if (typeof cursor !== "object" || cursor === null) {
return undefined;
}
cursor = (cursor as TomlObject)[part];
if (cursor === undefined) {
return undefined;
}
}
return typeof cursor === "string" ? cursor : undefined;
}
function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale && isSupportedLocale(pathLocale)) {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
if (browserLang.startsWith("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
import { createTomlTranslator } from "../../../common/core/i18n";
// eslint-disable-next-line import/no-unresolved
import commonEnRaw from "../../../common/locales/en.toml?raw";
// eslint-disable-next-line import/no-unresolved
import commonKoRaw from "../../../common/locales/ko.toml?raw";
// eslint-disable-next-line import/no-unresolved
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../locales/ko.toml?raw";
const translations: Record<Locale, TomlObject> = {
ko: parseToml(koRaw),
en: parseToml(enRaw),
};
function formatTemplate(
template: string,
vars?: Record<string, string | number>,
): string {
if (!vars) {
return template;
}
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
export function t(
key: string,
fallback?: string,
vars?: Record<string, string | number>,
): string {
const locale = detectLocale();
const value = getValue(translations[locale], key);
if (value && value.length > 0) {
return formatTemplate(value, vars);
}
return formatTemplate(fallback ?? key, vars);
}
export const t = createTomlTranslator({
ko: [commonKoRaw, koRaw],
en: [commonEnRaw, enRaw],
});

View File

@@ -1,76 +1,27 @@
import {
DEFAULT_SESSION_RENEW_THROTTLE_MS,
shouldAttemptSlidingSessionRenew as shouldAttemptSlidingSessionRenewBase,
shouldAttemptUnlimitedSessionRenew as shouldAttemptUnlimitedSessionRenewBase,
type SessionRenewDecisionParams,
} from "../../../common/core/session";
export const SESSION_RENEW_THROTTLE_MS = DEFAULT_SESSION_RENEW_THROTTLE_MS;
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
type SlidingSessionRenewDecisionParams = {
expiresAtSec?: number | null;
nowMs: number;
isEnabled: boolean;
isAuthenticated: boolean;
isLoading: boolean;
isRenewInFlight: boolean;
lastAttemptAtMs: number;
thresholdMs?: number;
throttleMs?: number;
};
export function shouldAttemptSlidingSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
export function shouldAttemptSlidingSessionRenew(
params: SessionRenewDecisionParams,
) {
return shouldAttemptSlidingSessionRenewBase({
...params,
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
});
}
export function shouldAttemptUnlimitedSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
export function shouldAttemptUnlimitedSessionRenew(
params: SessionRenewDecisionParams,
) {
return shouldAttemptUnlimitedSessionRenewBase({
...params,
thresholdMs: params.thresholdMs ?? SESSION_RENEW_THRESHOLD_MS,
});
}

View File

@@ -1,6 +1,8 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { mergeClassNames } from "../../../common/core/utils";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return mergeClassNames(twMerge, [clsx(inputs)]);
}

View File

@@ -302,15 +302,6 @@ no_custom = "No custom fields defined for this tenant."
[msg.admin.users.list.registry]
count = "Count"
[msg.common]
error = "Error"
loading = "Loading..."
no_description = "No Description."
parsing = "Parsing data..."
requesting = "Requesting..."
saving = "Saving..."
unknown_error = "unknown error"
[msg.dev]
logout_confirm = "Are you sure you want to log out?"
@@ -1176,73 +1167,6 @@ name = "Name"
role = "Role"
[ui.common]
add = "Add"
all = "All"
admin_only = "Admin Only"
assign = "Assign"
back = "Back"
back_to_login = "Back to login"
cancel = "Cancel"
change_file = "Change File"
clear_search = "Clear Search"
close = "Close"
collapse = "Collapse"
confirm = "Confirm"
copy = "Copy"
create = "Create"
delete = "Delete"
details = "Details"
edit = "Edit"
export = "Export"
fail = "Fail"
go_home = "Go Home"
view = "View"
hyphen = "-"
manage = "Manage"
na = "N/A"
never = "Never"
next = "Next"
none = "None"
page_of = "Page {{page}} of {{total}}"
prev = "Prev"
previous = "Previous"
qr = "QR"
reset = "Reset"
read_only = "Read Only"
refresh = "Refresh"
remove = "Remove"
resend = "Resend"
retry = "Retry"
save = "Save"
search = "Search"
select = "Select"
select_file = "Select File"
select_placeholder = "Select Placeholder"
show_more = "Show More"
language = "Language"
language_ko = "Korean"
language_en = "English"
success = "Success"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "Theme Toggle"
unknown = "Unknown"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "Active"
blocked = "Blocked"
failure = "Failure"
inactive = "Inactive"
ok = "Ok"
pending = "Pending"
success = "Success"
[test]
key = "Test"

View File

@@ -302,15 +302,6 @@ no_custom = "이 테넌트에 정의된 커스텀 필드가 없습니다."
[msg.admin.users.list.registry]
count = "총 {{count}}명의 사용자가 등록되어 있습니다."
[msg.common]
error = "오류가 발생했습니다."
loading = "로딩 중..."
no_description = "설명이 없습니다."
parsing = "데이터 파싱 중..."
requesting = "요청 중..."
saving = "저장 중..."
unknown_error = "알 수 없는 오류"
[msg.dev]
logout_confirm = "로그아웃 하시겠습니까?"
@@ -1178,73 +1169,6 @@ name = "이름"
role = "역할"
[ui.common]
add = "추가"
all = "전체"
admin_only = "관리자 전용"
assign = "할당"
back = "돌아가기"
back_to_login = "로그인으로 돌아가기"
cancel = "취소"
change_file = "파일 변경"
clear_search = "검색 초기화"
close = "닫기"
collapse = "접기"
confirm = "확인"
copy = "복사"
create = "생성"
delete = "삭제"
details = "상세정보"
edit = "편집"
export = "내보내기"
fail = "실패"
go_home = "홈으로"
view = "보기"
hyphen = "-"
manage = "관리"
na = "N/A"
never = "Never"
next = "다음"
none = "없음"
page_of = "Page {{page}} of {{total}}"
prev = "이전"
previous = "이전"
qr = "QR"
reset = "초기화"
read_only = "읽기 전용"
refresh = "새로고침"
remove = "제외"
resend = "재발송"
retry = "다시 시도"
save = "저장"
search = "검색"
select = "선택"
select_file = "파일 선택"
select_placeholder = "선택하세요"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
success = "성공"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
unknown = "Unknown"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"
[test]
key = "테스트"

View File

@@ -302,15 +302,6 @@ no_custom = ""
[msg.admin.users.list.registry]
count = ""
[msg.common]
error = ""
loading = ""
no_description = ""
parsing = ""
requesting = ""
saving = ""
unknown_error = ""
[msg.dev]
logout_confirm = ""
@@ -1177,73 +1168,6 @@ name = ""
role = ""
[ui.common]
add = ""
all = ""
admin_only = ""
assign = ""
back = ""
back_to_login = ""
cancel = ""
change_file = ""
clear_search = ""
close = ""
collapse = ""
confirm = ""
copy = ""
create = ""
delete = ""
details = ""
edit = ""
export = ""
fail = ""
go_home = ""
view = ""
hyphen = ""
manage = ""
na = ""
never = ""
next = ""
none = ""
page_of = ""
prev = ""
previous = ""
qr = ""
reset = ""
read_only = ""
refresh = ""
remove = ""
resend = ""
retry = ""
save = ""
search = ""
select = ""
select_file = ""
select_placeholder = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
success = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
unknown = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.status]
active = ""
blocked = ""
failure = ""
inactive = ""
ok = ""
pending = ""
success = ""
[test]
key = ""

View File

@@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme";
const config: Config = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
"../common/**/*.{ts,tsx,css}",
],
theme: {
container: {
center: true,

View File

@@ -27,8 +27,12 @@ if command -v rsync >/dev/null 2>&1; then
--exclude 'playwright-report' \
--exclude 'test-results' \
"$repo_root/adminfront/" "$tmp_dir/adminfront/"
rsync -rlptD --delete \
--exclude 'node_modules' \
"$repo_root/common/" "$tmp_dir/common/"
else
cp -R "$repo_root/adminfront" "$tmp_dir/adminfront"
cp -R "$repo_root/common" "$tmp_dir/common"
rm -rf "$tmp_dir/adminfront/node_modules" \
"$tmp_dir/adminfront/playwright-report" \
"$tmp_dir/adminfront/test-results"

View File

@@ -5,11 +5,27 @@ const fs = require('fs');
const path = require('path');
const ROOT_DIR = process.cwd();
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const LANG_FILES = ['ko.toml', 'en.toml'];
const FAIL_UNUSED = process.argv.includes('--fail-unused');
const LOCALE_SPECS = [
{
name: 'root',
label: 'root locales',
dir: path.join(ROOT_DIR, 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'),
},
{
name: 'common',
label: 'common locales',
dir: path.join(ROOT_DIR, 'common', 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'),
},
];
const SKIP_DIRS = new Set([
'.git',
'node_modules',
@@ -78,7 +94,6 @@ function parseTomlKeys(filePath) {
continue;
}
// Strip quotes if present
if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
}
@@ -138,6 +153,23 @@ function collectCodeKeys() {
return keys;
}
function shouldIgnoreCodeKey(key) {
return (
key.includes('.msg.') ||
key.includes('.ui.') ||
key.includes('.err.') ||
key.includes('.test.') ||
key.includes('.non.') ||
key.startsWith('ui.admin.users.list.table.') ||
key.startsWith('msg.admin.users.detail.') ||
key.startsWith('msg.dev.clients.') ||
key.startsWith('ui.admin.users.create.') ||
key.startsWith('ui.admin.users.detail.') ||
key.startsWith('ui.dev.clients.') ||
key.startsWith('ui.dev.session.')
);
}
function difference(aSet, bSet) {
const result = [];
for (const item of aSet) {
@@ -158,72 +190,75 @@ function printList(title, items) {
}
}
function main() {
const errors = [];
const warnings = [];
const templateResult = parseTomlKeys(TEMPLATE_PATH);
function collectSpecResources(spec) {
const templatePath = path.join(spec.dir, spec.template);
const templateResult = parseTomlKeys(templatePath);
if (!templateResult.ok) {
errors.push(templateResult.error);
return { ok: false, error: templateResult.error };
}
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {
const langPath = path.join(LOCALES_DIR, fileName);
for (const fileName of spec.langs) {
const langPath = path.join(spec.dir, fileName);
const langResult = parseTomlKeys(langPath);
if (!langResult.ok) {
errors.push(langResult.error);
continue;
return { ok: false, error: langResult.error };
}
langKeyMap.set(fileName, langResult.keys);
}
if (errors.length > 0) {
console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.');
for (const error of errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
return {
ok: true,
templateKeys: templateResult.keys,
langKeyMap,
};
}
const templateKeys = templateResult.keys;
const rawCodeKeys = Array.from(collectCodeKeys());
const codeKeysArray = rawCodeKeys.filter(k =>
!k.includes('.msg.') &&
!k.includes('.ui.') &&
!k.includes('.err.') &&
!k.includes('.test.') &&
!k.includes('.non.') &&
!k.startsWith("ui.admin.users.list.table.") &&
!k.startsWith("msg.admin.users.detail.") &&
!k.startsWith("msg.common.") &&
!k.startsWith("msg.dev.clients.") &&
!k.startsWith("ui.admin.users.create.") &&
!k.startsWith("ui.admin.users.detail.") &&
!k.startsWith("ui.common.") &&
!k.startsWith("ui.dev.clients.") &&
!k.startsWith("ui.dev.session.")
function main() {
const errors = [];
const warnings = [];
const rawCodeKeys = Array.from(collectCodeKeys()).filter(
(key) => !shouldIgnoreCodeKey(key),
);
const codeKeys = new Set(codeKeysArray);
const codeKeys = new Set(rawCodeKeys);
for (const [fileName, langKeys] of langKeyMap.entries()) {
const missingInLang = difference(templateKeys, langKeys);
if (missingInLang.length > 0) {
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}`);
printList(`${fileName}에 없는 키`, missingInLang);
for (const spec of LOCALE_SPECS) {
const resources = collectSpecResources(spec);
if (!resources.ok) {
errors.push(resources.error);
continue;
}
}
const missingInTemplate = difference(codeKeys, templateKeys);
if (missingInTemplate.length > 0) {
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}`);
printList('template.toml에 없는 코드 사용 키', missingInTemplate);
}
for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
const missingInLang = difference(resources.templateKeys, langKeys);
if (missingInLang.length > 0) {
errors.push(
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}`,
);
printList(`${spec.label} ${fileName}에 없는 키`, missingInLang);
}
}
const unusedInTemplate = difference(templateKeys, codeKeys);
if (unusedInTemplate.length > 0) {
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}`);
printList('코드에서 사용되지 않는 키', unusedInTemplate);
const ownedCodeKeys = new Set(
rawCodeKeys.filter((key) => spec.ownsKey(key)),
);
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
if (missingInTemplate.length > 0) {
errors.push(
`[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}`,
);
printList(`${spec.label} template.toml에 없는 코드 사용 키`, missingInTemplate);
}
const unusedInTemplate = difference(resources.templateKeys, codeKeys);
if (unusedInTemplate.length > 0) {
warnings.push(
`[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}`,
);
printList(`${spec.label} 코드에서 사용되지 않는 키`, unusedInTemplate);
}
}
if (errors.length > 0) {

View File

@@ -5,9 +5,25 @@ const fs = require('fs');
const path = require('path');
const ROOT_DIR = process.cwd();
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
const LANG_FILES = ['ko.toml', 'en.toml'];
const LOCALE_SPECS = [
{
name: 'root',
label: 'root locales',
dir: path.join(ROOT_DIR, 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'),
},
{
name: 'common',
label: 'common locales',
dir: path.join(ROOT_DIR, 'common', 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'),
},
];
const SKIP_DIRS = new Set([
'.git',
@@ -81,7 +97,6 @@ function parseTomlKeys(filePath) {
continue;
}
// Strip quotes if present
if (key.startsWith('"') && key.endsWith('"')) {
key = key.slice(1, -1);
}
@@ -141,22 +156,20 @@ function collectCodeKeys() {
return keys;
}
function filterCodeKeys(rawKeys) {
return Array.from(rawKeys).filter((k) =>
!k.includes('.msg.') &&
!k.includes('.ui.') &&
!k.includes('.err.') &&
!k.includes('.test.') &&
!k.includes('.non.') &&
!k.startsWith('ui.admin.users.list.table.') &&
!k.startsWith('msg.admin.users.detail.') &&
!k.startsWith('msg.common.') &&
!k.startsWith('msg.dev.clients.') &&
!k.startsWith('ui.admin.users.create.') &&
!k.startsWith('ui.admin.users.detail.') &&
!k.startsWith('ui.common.') &&
!k.startsWith('ui.dev.clients.') &&
!k.startsWith('ui.dev.session.')
function shouldIgnoreCodeKey(key) {
return (
key.includes('.msg.') ||
key.includes('.ui.') ||
key.includes('.err.') ||
key.includes('.test.') ||
key.includes('.non.') ||
key.startsWith('ui.admin.users.list.table.') ||
key.startsWith('msg.admin.users.detail.') ||
key.startsWith('msg.dev.clients.') ||
key.startsWith('ui.admin.users.create.') ||
key.startsWith('ui.admin.users.detail.') ||
key.startsWith('ui.dev.clients.') ||
key.startsWith('ui.dev.session.')
);
}
@@ -170,62 +183,82 @@ function difference(aSet, bSet) {
return result.sort();
}
function collectSpecResources(spec) {
const templatePath = path.join(spec.dir, spec.template);
const templateResult = parseTomlKeys(templatePath);
if (!templateResult.ok) {
return { ok: false, error: templateResult.error };
}
const langKeyMap = new Map();
for (const fileName of spec.langs) {
const langPath = path.join(spec.dir, fileName);
const langResult = parseTomlKeys(langPath);
if (!langResult.ok) {
return { ok: false, error: langResult.error };
}
langKeyMap.set(fileName, langResult.keys);
}
return {
ok: true,
templateKeys: templateResult.keys,
langKeyMap,
};
}
function buildReport() {
const report = {
generated_at: new Date().toISOString(),
errors: [],
warnings: [],
details: {
details: {},
};
const rawCodeKeys = Array.from(collectCodeKeys()).filter(
(key) => !shouldIgnoreCodeKey(key),
);
const codeKeys = new Set(rawCodeKeys);
for (const spec of LOCALE_SPECS) {
const resources = collectSpecResources(spec);
report.details[spec.name] = {
missing_in_template: [],
missing_in_lang: {},
unused_in_template: [],
},
};
};
const templateResult = parseTomlKeys(TEMPLATE_PATH);
if (!templateResult.ok) {
report.errors.push(templateResult.error);
return report;
}
const templateKeys = templateResult.keys;
const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {
const langPath = path.join(LOCALES_DIR, fileName);
const langResult = parseTomlKeys(langPath);
if (!langResult.ok) {
report.errors.push(langResult.error);
if (!resources.ok) {
report.errors.push(resources.error);
continue;
}
langKeyMap.set(fileName, langResult.keys);
}
for (const [fileName, langKeys] of langKeyMap.entries()) {
const missingInLang = difference(templateKeys, langKeys);
if (missingInLang.length > 0) {
report.errors.push(
`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}`,
);
report.details.missing_in_lang[fileName] = missingInLang;
for (const [fileName, langKeys] of resources.langKeyMap.entries()) {
const missingInLang = difference(resources.templateKeys, langKeys);
if (missingInLang.length > 0) {
report.errors.push(
`[Sync Error] ${spec.label} ${fileName} 누락 키 ${missingInLang.length}`,
);
report.details[spec.name].missing_in_lang[fileName] = missingInLang;
}
}
}
const missingInTemplate = difference(codeKeys, templateKeys);
if (missingInTemplate.length > 0) {
report.errors.push(
`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}`,
);
report.details.missing_in_template = missingInTemplate;
}
const ownedCodeKeys = new Set(rawCodeKeys.filter((key) => spec.ownsKey(key)));
const missingInTemplate = difference(ownedCodeKeys, resources.templateKeys);
if (missingInTemplate.length > 0) {
report.errors.push(
`[Missing Key] ${spec.label} template.toml 누락 키 ${missingInTemplate.length}`,
);
report.details[spec.name].missing_in_template = missingInTemplate;
}
const unusedInTemplate = difference(templateKeys, codeKeys);
if (unusedInTemplate.length > 0) {
report.warnings.push(
`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}`,
);
report.details.unused_in_template = unusedInTemplate;
const unusedInTemplate = difference(resources.templateKeys, codeKeys);
if (unusedInTemplate.length > 0) {
report.warnings.push(
`[Unused Key] ${spec.label} template.toml 미사용 키 ${unusedInTemplate.length}`,
);
report.details[spec.name].unused_in_template = unusedInTemplate;
}
}
return report;
@@ -258,8 +291,12 @@ function main() {
fs.writeFileSync(summaryPath, lines.join('\n'));
if (report.errors.length > 0) {
console.error('❌ i18n report generated with errors');
process.exit(1);
}
console.log(`✅ i18n report written to ${outPath}`);
console.log(`✅ i18n summary written to ${summaryPath}`);
}
main();

View File

@@ -95,10 +95,14 @@ async function mockUserfrontApis(
pendingRef?: string;
};
pendingRef = body.pendingRef ?? null;
} catch (_) {
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve POST body:`, body);
} catch (e) {
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve POST body parse error:`, e);
pendingRef = null;
}
options.captureApprove?.(pendingRef);
} else {
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`);
}
await route.fulfill({
status: 200,

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/web_window.dart';
class ApproveQrScreen extends StatefulWidget {
final String? pendingRef;
@@ -60,7 +61,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final target = buildLocalizedSigninPath(Uri.base);
context.go('$target?notice=qr_login_required');
webWindow.redirectTo('$target?notice=qr_login_required');
});
}
}
@@ -102,7 +103,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
if (storedToken == null && !hasCookie) {
if (mounted) {
final target = buildLocalizedSigninPath(Uri.base);
context.go('$target?notice=qr_login_required');
webWindow.redirectTo('$target?notice=qr_login_required');
}
return;
}

View File

@@ -53,6 +53,20 @@ Map<String, dynamic>? _decodeErrorDetails(String? raw) {
return null;
}
bool _hasActiveLocalSession() {
final token = AuthTokenStore.getToken();
return (token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
}
String? _redirectPrivateLocaleRoute(GoRouterState state) {
if (_hasActiveLocalSession()) {
return null;
}
final localeCode =
extractLocaleFromPath(state.uri) ?? resolvePreferredLocaleCode();
return buildSigninRedirectPath(localeCode, state.uri);
}
void _attemptRecoveryFromNullCheck({
required Object exception,
StackTrace? stackTrace,
@@ -243,25 +257,18 @@ final _router = GoRouter(
routes: [
GoRoute(
path: '/:locale',
redirect: (context, state) {
// /{locale} 진입은 화면 렌더링 없이 단일 목적지로만 보냅니다.
if (state.uri.pathSegments.length != 1) {
return null;
}
builder: (context, state) {
final rawLocale = state.pathParameters['locale'];
final localeCode = normalizeLocaleCode(rawLocale);
final token = AuthTokenStore.getToken();
final isLoggedIn =
(token != null && token.isNotEmpty) ||
AuthTokenStore.usesCookie();
if (!isLoggedIn) {
return buildSigninRedirectPath(localeCode, state.uri);
}
return '/$localeCode/dashboard';
return ScopedTheme(
controller: ThemeController.auth,
child: LocaleEntryRedirectScreen(localeCode: localeCode),
);
},
routes: [
GoRoute(
path: 'dashboard',
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
builder: (context, state) {
return ScopedTheme(
controller: ThemeController.app,
@@ -271,6 +278,7 @@ final _router = GoRouter(
),
GoRoute(
path: 'profile',
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const ProfilePage(),
@@ -430,6 +438,19 @@ final _router = GoRouter(
),
GoRoute(
path: 'approve',
redirect: (context, state) {
final token = AuthTokenStore.getToken();
final isLoggedIn =
(token != null && token.isNotEmpty) ||
AuthTokenStore.usesCookie();
if (isLoggedIn) {
return null;
}
final localeCode =
extractLocaleFromPath(state.uri) ??
resolvePreferredLocaleCode();
return '/$localeCode/signin?notice=qr_login_required';
},
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ApproveQrScreen(
@@ -439,6 +460,17 @@ final _router = GoRouter(
),
GoRoute(
path: 'ql/:ref',
redirect: (context, state) {
final localeCode =
extractLocaleFromPath(state.uri) ??
resolvePreferredLocaleCode();
final pendingRef = state.pathParameters['ref'];
if (pendingRef == null || pendingRef.isEmpty) {
return '/$localeCode/approve';
}
final encodedRef = Uri.encodeQueryComponent(pendingRef);
return '/$localeCode/approve?ref=$encodedRef';
},
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
@@ -446,6 +478,7 @@ final _router = GoRouter(
),
GoRoute(
path: 'scan',
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const QRScanScreen(),
@@ -453,6 +486,7 @@ final _router = GoRouter(
),
GoRoute(
path: 'admin/users',
redirect: (context, state) => _redirectPrivateLocaleRoute(state),
builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const UserManagementScreen(),
@@ -478,6 +512,10 @@ final _router = GoRouter(
(token != null && token.isNotEmpty) || AuthTokenStore.usesCookie();
final path = stripLocalePath(uri);
if (!isLoggedIn && (path == '/approve' || path.startsWith('/ql/'))) {
return '/$requestedLocale/signin?notice=qr_login_required';
}
final isPublicPath = isPublicAuthPath(path, uri);
if (isPublicPath) {
@@ -499,9 +537,24 @@ final _router = GoRouter(
},
);
class BaronSSOApp extends StatelessWidget {
class BaronSSOApp extends StatefulWidget {
const BaronSSOApp({super.key});
@override
State<BaronSSOApp> createState() => _BaronSSOAppState();
}
class _BaronSSOAppState extends State<BaronSSOApp> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
// Re-run router redirects after the first frame so session-only web
// storage state is reflected even when startup routing evaluated too early.
AuthNotifier.instance.notify();
});
}
@override
Widget build(BuildContext context) {
final localization = EasyLocalization.of(context);
@@ -531,3 +584,49 @@ class BaronSSOApp extends StatelessWidget {
);
}
}
class LocaleEntryRedirectScreen extends StatefulWidget {
const LocaleEntryRedirectScreen({super.key, required this.localeCode});
final String localeCode;
@override
State<LocaleEntryRedirectScreen> createState() =>
_LocaleEntryRedirectScreenState();
}
class _LocaleEntryRedirectScreenState extends State<LocaleEntryRedirectScreen> {
bool _redirected = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_redirect();
});
}
void _redirect() {
if (!mounted || _redirected) {
return;
}
// This parent route is also built for nested locale routes, so only
// redirect when the current location is exactly `/{locale}`.
if (stripLocalePath(Uri.base) != '/') {
return;
}
_redirected = true;
if (!_hasActiveLocalSession()) {
context.go('/${widget.localeCode}/signin');
return;
}
context.go('/${widget.localeCode}/dashboard');
}
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
cli_config:
dependency: transitive
description:
@@ -276,6 +276,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -328,18 +336,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
@@ -661,26 +669,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.12"
toml:
dependency: "direct main"
description: