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

@@ -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 = ""