첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

1
baron-sso/common/.npmrc Normal file
View File

@@ -0,0 +1 @@
confirmModulesPurge=false

View File

@@ -0,0 +1,3 @@
{
"extends": ["./config/biome.base.json"]
}

View File

@@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"style": {
"useNodejsImportProtocol": "off",
"useEnumInitializers": "off"
},
"suspicious": {
"noUnknownAtRules": "off"
},
"a11y": {
"noLabelWithoutControl": "off"
}
}
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"files": {
"includes": [
"**",
"!**/dist/**",
"!**/.vite/**",
"!**/node_modules/**",
"!**/coverage/**",
"!**/tsconfig*.json",
"!**/test-results/**",
"!**/test-results.nobody-backup/**",
"!**/playwright-report/**"
]
}
}

View File

@@ -0,0 +1,67 @@
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig, type UserConfig } from "vite";
const require = createRequire(import.meta.url);
const commonWorkspaceDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const appWorkspaceDir = path.resolve(process.cwd());
const reactPackageDir = path.dirname(require.resolve("react/package.json"));
const reactDomPackageDir = path.dirname(
require.resolve("react-dom/package.json"),
);
export const commonViteConfig: UserConfig = {
plugins: [react()],
resolve: {
// 공용 패키지에서 hook를 쓰는 컴포넌트를 가져올 때 React가 중복 로드되면
// dispatcher가 분리되어 useState/useEffect가 런타임에 깨질 수 있습니다.
alias: {
react: reactPackageDir,
"react-dom": reactDomPackageDir,
},
dedupe: ["react", "react-dom"],
},
build: {
emptyOutDir: true,
},
server: {
fs: {
allow: [appWorkspaceDir, commonWorkspaceDir, "/workspace/common"],
},
},
};
export function hostFromUrl(value: string | undefined) {
if (!value) return undefined;
try {
return new URL(value).hostname;
} catch {
return value;
}
}
export function getAllowedHosts(
defaultHosts: string[],
envUrl?: string,
envAllowedHosts?: string,
) {
return Array.from(
new Set(
[
...defaultHosts,
hostFromUrl(envUrl),
...(envAllowedHosts ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean),
].filter((host): host is string => Boolean(host)),
),
);
}
export default defineConfig(commonViteConfig);

View File

@@ -0,0 +1,92 @@
export type CommonAuditLog = {
event_id: string;
timestamp: string;
user_id: string;
event_type: string;
status: string;
ip_address: string;
user_agent: string;
device_id?: string;
details?: string;
};
export type AuditDetails = {
request_id?: string;
method?: string;
path?: string;
status?: number;
latency_ms?: number;
error?: string;
tenant_id?: string;
actor_id?: string;
action?: string;
target?: string;
target_id?: string;
before?: unknown;
after?: unknown;
};
export function parseAuditDetails(details?: string): AuditDetails {
if (!details) {
return {};
}
try {
const parsed = JSON.parse(details);
if (parsed && typeof parsed === "object") {
return parsed as AuditDetails;
}
} catch {}
return {};
}
export function formatAuditValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "-";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function formatAuditDateParts(value: string) {
if (!value) {
return { date: "-", time: "-" };
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return { date: value, time: "-" };
}
return {
date: parsed.toISOString().slice(0, 10),
time: parsed.toLocaleTimeString("ko-KR", { hour12: false }),
};
}
export function resolveAuditActor(
log: Pick<CommonAuditLog, "user_id">,
details: AuditDetails,
) {
return log.user_id || details.actor_id || "-";
}
export function resolveAuditAction(
log: Pick<CommonAuditLog, "event_type">,
details: AuditDetails,
) {
if (details.action) {
return details.action;
}
if (details.method && details.path) {
return `${details.method} ${details.path}`;
}
return log.event_type;
}
export function resolveAuditTarget(details: AuditDetails) {
return details.target || details.target_id || "-";
}

View File

@@ -0,0 +1,87 @@
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;
};
export type LoginRedirectGuardParams = {
pathname: string;
isRedirecting: boolean;
loginPath?: string;
callbackPath?: string;
};
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 || "",
};
}
export function shouldStartLoginRedirect({
pathname,
isRedirecting,
loginPath = "/login",
callbackPath = DEFAULT_OIDC_REDIRECT_PATH,
}: LoginRedirectGuardParams) {
if (isRedirecting) {
return false;
}
if (pathname === loginPath || pathname.startsWith(callbackPath)) {
return false;
}
return true;
}

View File

@@ -0,0 +1,148 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CommonAuditLog } from "../../audit";
import { AuditLogTable } from "./AuditLogTable";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
document.body.innerHTML = "";
});
function renderTable(props: Parameters<typeof AuditLogTable>[0]) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<AuditLogTable {...props} />);
});
return { container };
}
const logs: CommonAuditLog[] = [
{
event_id: "evt-1",
timestamp: "2026-05-28T06:07:18.000Z",
user_id: "user-1",
event_type: "CLIENT_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
device_id: "device-1",
details: JSON.stringify({
request_id: "req-1",
method: "POST",
path: "/api/v1/clients",
latency_ms: 120,
tenant_id: "tenant-1",
actor_id: "user-1",
action: "업데이트",
target_id: "client-a",
before: { status: "inactive" },
after: { status: "active" },
}),
},
];
describe("AuditLogTable", () => {
it("renders loading and empty states", () => {
const { container: loadingContainer } = renderTable({
logs: [],
t: (key, fallback) => fallback ?? key,
loading: true,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
expect(loadingContainer.textContent).toContain("Loading audit logs...");
const { container: emptyContainer } = renderTable({
logs: [],
t: (key, fallback) => fallback ?? key,
loading: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
expect(emptyContainer.textContent).toContain("No audit logs found.");
expect(emptyContainer.textContent).toContain("End of audit feed");
});
it("renders rows, expands details, copies fields, and loads more", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
const onLoadMore = vi.fn();
const { container } = renderTable({
logs,
t: (key, fallback, vars) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
loading: false,
hasNextPage: true,
isFetchingNextPage: false,
onLoadMore,
});
expect(container.textContent).toContain("user-1");
expect(container.textContent).toContain("업데이트");
expect(container.textContent).toContain("client-a");
expect(container.textContent).toContain("success");
const buttons = Array.from(container.querySelectorAll("button"));
const actorCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy User ID",
);
const targetCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy Client ID",
);
const expandButton = buttons.find(
(button) => !button.getAttribute("aria-label") && !button.textContent,
);
const loadMoreButton = buttons.find(
(button) => button.textContent === "Load more",
);
expect(actorCopyButton).toBeTruthy();
expect(targetCopyButton).toBeTruthy();
expect(expandButton).toBeTruthy();
expect(loadMoreButton).toBeTruthy();
await act(async () => {
actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith("user-1");
expect(writeText).toHaveBeenCalledWith("client-a");
expect(container.textContent).toContain("Request ID · req-1");
expect(container.textContent).toContain("Actor");
expect(container.textContent).toContain("Result");
await act(async () => {
loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,409 @@
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react";
import {
getCommonBadgeClasses,
type CommonBadgeVariant,
} from "../../../ui/badge";
import { getCommonButtonClasses } from "../../../ui/button";
import {
commonStickyTableHeaderClass,
commonTableBodyClass,
commonTableCellClass,
commonTableClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableShellClass,
commonTableViewportClass,
commonTableWrapperClass,
} from "../../../ui/table";
import type { CommonAuditLog } from "../../audit";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../audit";
type AuditTranslate = (
key: string,
fallback: string,
vars?: Record<string, string | number>,
) => string;
type AuditLogTableProps = {
logs: CommonAuditLog[];
t: AuditTranslate;
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
className?: string;
};
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
function statusVariant(status: string): CommonBadgeVariant {
switch (status.toLowerCase()) {
case "success":
case "ok":
return "success";
case "failure":
case "error":
case "blocked":
return "destructive";
case "pending":
case "warning":
return "warning";
default:
return "default";
}
}
export function AuditLogTable({
logs,
t,
loading,
hasNextPage,
isFetchingNextPage,
onLoadMore,
className,
}: AuditLogTableProps) {
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
return (
<div className={cx(commonTableShellClass, className)}>
<div className={cx(commonTableViewportClass, "flex-1")}>
<div className={commonTableWrapperClass}>
<Table className={commonTableClass}>
<TableHeader className={commonTableHeaderClass}>
<TableRow className={cx(commonTableRowClass, commonStickyTableHeaderClass)}>
<TableHead className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")}
</TableHead>
<TableHead className={cx(commonTableHeadClass, "w-[80px]")} />
</TableRow>
</TableHeader>
<TableBody className={commonTableBodyClass}>
{logs.map((log, index) => {
const details = parseAuditDetails(log.details);
const actorLabel = resolveAuditActor(log, details);
const actionLabel = resolveAuditAction(log, details);
const targetLabel = resolveAuditTarget(details);
const rowKey = `${log.event_id}-${log.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
const { date, time } = formatAuditDateParts(log.timestamp);
return (
<React.Fragment key={rowKey}>
<TableRow className={cx(commonTableRowClass, "bg-card/40")}>
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
</TableCell>
<TableCell className={commonTableCellClass}>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy User ID",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</TableCell>
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
<div className="font-semibold text-foreground">
{actionLabel}
</div>
</TableCell>
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.target",
"Copy Client ID",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</TableCell>
<TableCell className={commonTableCellClass}>
<span
className={getCommonBadgeClasses({
variant: statusVariant(log.status),
})}
>
{log.status}
</span>
</TableCell>
<TableCell className={cx(commonTableCellClass, "text-right")}>
<button
type="button"
className={getCommonButtonClasses({
variant: "ghost",
size: "sm",
})}
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}))
}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</TableCell>
</TableRow>
{expanded && (
<TableRow className={cx(commonTableRowClass, "bg-card/20")}>
<TableCell colSpan={6} className={cx(commonTableCellClass, "text-xs")}>
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.request", "Request")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{ value: formatAuditValue(details.request_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{ value: formatAuditValue(log.event_id) },
)}
</div>
<div>
{t("ui.common.audit.details.ip", "IP · {{value}}", {
value: formatAuditValue(log.ip_address),
})}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.method",
"Method · {{value}}",
{ value: formatAuditValue(details.method) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.path",
"Path · {{value}}",
{ value: formatAuditValue(details.path) },
)}
</div>
<div>
{t(
"ui.common.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"User ID · {{value}}",
{ value: actorLabel },
)}
</div>
<div>
{t(
"ui.common.audit.details.tenant",
"Tenant · {{value}}",
{ value: formatAuditValue(details.tenant_id) },
)}
</div>
<div>
{t(
"ui.common.audit.details.device",
"Device · {{value}}",
{ value: formatAuditValue(log.device_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.target",
"Client ID · {{value}}",
{ value: targetLabel },
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.result", "Result")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.error",
"Error · {{value}}",
{ value: formatAuditValue(details.error) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.before",
"Before · {{value}}",
{ value: formatAuditValue(details.before) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.after",
"After · {{value}}",
{ value: formatAuditValue(details.after) },
)}
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
{logs.length === 0 && !loading && (
<TableRow className={commonTableRowClass}>
<TableCell
colSpan={6}
className={cx(
commonTableCellClass,
"text-center text-muted-foreground py-8",
)}
>
{t("msg.common.audit.empty", "No audit logs found.")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="p-4 border-t text-center flex-shrink-0 bg-background/50 backdrop-blur-sm z-10">
{hasNextPage ? (
<div className="flex flex-col items-center gap-2">
{isFetchingNextPage && (
<span className="text-xs text-muted-foreground animate-pulse">
{t("msg.common.loading", "Loading more...")}
</span>
)}
<button
type="button"
className={getCommonButtonClasses({
variant: "outline",
size: "sm",
})}
onClick={onLoadMore}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "Load more")}
</button>
</div>
) : logs.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")}
</span>
) : null}
</div>
</div>
);
}
// Internal table components for cleaner implementation
function Table({ className, children, style }: { className?: string, children: React.ReactNode, style?: React.CSSProperties }) {
return <table className={className} style={style}>{children}</table>;
}
function TableHeader({ className, children }: { className?: string, children: React.ReactNode }) {
return <thead className={className}>{children}</thead>;
}
function TableBody({ className, children }: { className?: string, children: React.ReactNode }) {
return <tbody className={className}>{children}</tbody>;
}
function TableRow({ className, children }: { className?: string, children: React.ReactNode }) {
return <tr className={className}>{children}</tr>;
}
function TableHead({ className, children }: { className?: string, children?: React.ReactNode }) {
return <th className={className}>{children}</th>;
}
function TableCell({ className, children, colSpan }: { className?: string, children: React.ReactNode, colSpan?: number }) {
return <td className={className} colSpan={colSpan}>{children}</td>;
}

View File

@@ -0,0 +1 @@
export * from "./AuditLogTable";

View File

@@ -0,0 +1,14 @@
export function OverviewAxisNotes({
xAxisLabel,
yAxisLabel,
}: {
xAxisLabel: string;
yAxisLabel: string;
}) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{xAxisLabel}</span>
<span>{yAxisLabel}</span>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
export function OverviewMetric({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
<span className="text-muted-foreground">{icon}</span>
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">{value}</span>
</span>
);
}

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
type OverviewSelectionChipOption = {
id: string;
label: ReactNode;
};
export function OverviewSelectionChips({
allLabel,
options,
selectedIds,
onSelectAll,
onToggle,
}: {
allLabel: string;
options: OverviewSelectionChipOption[];
selectedIds: string[];
onSelectAll: () => void;
onToggle: (id: string) => void;
}) {
const isAllSelected = selectedIds.length === 0;
return (
<div className="flex flex-wrap gap-2 rounded-xl border border-border/60 bg-card/60 p-3">
<label className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs">
<input
type="checkbox"
checked={isAllSelected}
onChange={onSelectAll}
className="h-3.5 w-3.5"
/>
<span>{allLabel}</span>
</label>
{options.map((option) => (
<label
key={option.id}
className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs"
>
<input
type="checkbox"
checked={selectedIds.includes(option.id)}
onChange={() => onToggle(option.id)}
className="h-3.5 w-3.5"
/>
<span>{option.label}</span>
</label>
))}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { OverviewAxisNotes } from "./OverviewAxisNotes";
export { OverviewMetric } from "./OverviewMetric";
export { OverviewSelectionChips } from "./OverviewSelectionChips";

View File

@@ -0,0 +1,68 @@
import type { ElementType, HTMLAttributes, ReactNode } from "react";
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
type PageHeaderProps = Omit<HTMLAttributes<HTMLElement>, "title"> & {
actions?: ReactNode;
icon?: ReactNode;
as?: ElementType;
description?: ReactNode;
eyebrow?: ReactNode;
sticky?: boolean;
title: ReactNode;
titleAs?: ElementType;
};
export function PageHeader({
actions,
as,
className,
description,
eyebrow,
icon,
sticky = false,
title,
titleAs,
...props
}: PageHeaderProps) {
const Root = as ?? "header";
const Title = titleAs ?? "h1";
return (
<Root
className={cx(
"flex flex-wrap items-start justify-between gap-4",
sticky &&
"sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pt-4 pb-2 backdrop-blur",
className,
)}
{...props}
>
<div className="flex min-w-0 items-start gap-3">
{icon ? (
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
{icon}
</div>
) : null}
<div className="space-y-2">
{eyebrow ? (
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{eyebrow}
</p>
) : null}
<Title className="text-3xl font-semibold tracking-tight">
{title}
</Title>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
</div>
{actions ? (
<div className="flex flex-wrap items-center gap-2">{actions}</div>
) : null}
</Root>
);
}

View File

@@ -0,0 +1 @@
export * from "./PageHeader";

View File

@@ -0,0 +1,170 @@
import type { ReactNode, ThHTMLAttributes } from "react";
import {
commonStickyTableHeaderClass,
commonTableHeadClass,
} from "../../../ui/table";
import type { SortConfig } from "../../utils";
export const sortableTableHeadBaseClassName = commonTableHeadClass;
export const sortableTableHeaderClassName = commonStickyTableHeaderClass;
function SortAscendingIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 5-5 5" />
<path d="m12 5 5 5" />
<path d="M12 19V5" />
</svg>
);
}
function SortDescendingIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12 19-5-5" />
<path d="m12 19 5-5" />
<path d="M12 5v14" />
</svg>
);
}
function SortIdleIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="ml-1 h-3.5 w-3.5 opacity-50"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
);
}
type SortableTableHeadAlign = "left" | "center" | "right";
function alignClassName(align: SortableTableHeadAlign) {
switch (align) {
case "center":
return "text-center";
case "right":
return "text-right";
default:
return "text-left";
}
}
function buttonAlignClassName(align: SortableTableHeadAlign) {
switch (align) {
case "center":
return "justify-center";
case "right":
return "justify-end";
default:
return "justify-start";
}
}
function sortAriaValue(
isActive: boolean,
direction: "asc" | "desc" | null,
): ThHTMLAttributes<HTMLTableCellElement>["aria-sort"] {
if (!isActive || direction === null) {
return "none";
}
return direction === "asc" ? "ascending" : "descending";
}
type SortableTableHeadProps<Key extends string> = Omit<
ThHTMLAttributes<HTMLTableCellElement>,
"children"
> & {
align?: SortableTableHeadAlign;
contentClassName?: string;
disabled?: boolean;
label: ReactNode;
onSort: (key: Key) => void;
sortConfig: SortConfig<Key> | null;
sortKey: Key;
};
export function SortableTableHead<Key extends string>({
align = "left",
className = "",
contentClassName = "",
disabled = false,
label,
onSort,
sortConfig,
sortKey,
...props
}: SortableTableHeadProps<Key>) {
const isActive = sortConfig?.key === sortKey;
const direction = isActive ? (sortConfig?.direction ?? null) : null;
return (
<th
aria-sort={sortAriaValue(isActive, direction)}
className={[
sortableTableHeadBaseClassName,
alignClassName(align),
disabled ? "" : "transition-colors hover:bg-muted/50",
className,
]
.filter(Boolean)
.join(" ")}
{...props}
>
<button
type="button"
onClick={() => onSort(sortKey)}
disabled={disabled}
className={[
"flex w-full items-center font-inherit",
buttonAlignClassName(align),
disabled ? "cursor-default opacity-70" : "cursor-pointer",
contentClassName,
]
.filter(Boolean)
.join(" ")}
>
<span>{label}</span>
{direction === "asc" ? (
<span className="ml-1 inline-flex">
<SortAscendingIcon />
</span>
) : direction === "desc" ? (
<span className="ml-1 inline-flex">
<SortDescendingIcon />
</span>
) : (
<SortIdleIcon />
)}
</button>
</th>
);
}

View File

@@ -0,0 +1 @@
export * from "./SortableTableHead";

View File

@@ -0,0 +1,11 @@
export { createTomlTranslator } from "./loader";
export {
DEFAULT_LOCALE,
LOCALE_STORAGE_KEY,
type Locale,
SUPPORTED_LOCALES,
type TomlObject,
type TomlValue,
type TranslatorInput,
type TranslatorOptions,
} from "./types";

View File

@@ -0,0 +1,182 @@
import {
DEFAULT_LOCALE,
LOCALE_STORAGE_KEY,
type Locale,
SUPPORTED_LOCALES,
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);
};
}

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,85 @@
import type { CursorFetchRequest, CursorPageResponse } from "./cursorFetchCore";
import { fetchAllCursorPagesMainThread } from "./cursorFetchCore";
type CursorWorkerResponseMessage<TItem> =
| {
id: string;
ok: true;
response: CursorPageResponse<TItem>;
}
| {
id: string;
ok: false;
error: string;
};
function createRequestId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function shouldUseWorker(useWorker: boolean | undefined) {
if (useWorker === false || typeof Worker === "undefined") {
return false;
}
const maybeWindow = globalThis as typeof globalThis & {
window?: Window & typeof globalThis & { _IS_TEST_MODE?: boolean };
};
return maybeWindow.window?._IS_TEST_MODE !== true;
}
async function fetchAllCursorPagesInWorker<TItem>(
request: CursorFetchRequest,
): Promise<CursorPageResponse<TItem>> {
const worker = new Worker(
new URL("./cursorFetch.worker.ts", import.meta.url),
{
type: "module",
},
);
const id = createRequestId();
return new Promise((resolve, reject) => {
worker.onmessage = (
event: MessageEvent<CursorWorkerResponseMessage<TItem>>,
) => {
if (event.data.id !== id) {
return;
}
worker.terminate();
if (event.data.ok) {
resolve(event.data.response);
} else {
reject(new Error(event.data.error));
}
};
worker.onerror = (event) => {
worker.terminate();
reject(new Error(event.message || "Cursor worker failed"));
};
worker.postMessage({ id, request });
});
}
export async function fetchAllCursorPages<TItem>(
request: CursorFetchRequest & { useWorker?: boolean },
): Promise<CursorPageResponse<TItem>> {
if (shouldUseWorker(request.useWorker)) {
try {
return await fetchAllCursorPagesInWorker<TItem>(request);
} catch {
return fetchAllCursorPagesMainThread<TItem>(request);
}
}
return fetchAllCursorPagesMainThread<TItem>(request);
}
export type { CursorFetchRequest, CursorPageResponse } from "./cursorFetchCore";
export { fetchAllCursorPagesMainThread } from "./cursorFetchCore";

View File

@@ -0,0 +1,44 @@
import {
type CursorFetchRequest,
type CursorPageResponse,
fetchAllCursorPagesMainThread,
} from "./cursorFetchCore";
type CursorWorkerRequestMessage = {
id: string;
request: CursorFetchRequest;
};
type CursorWorkerResponseMessage<TItem> =
| {
id: string;
ok: true;
response: CursorPageResponse<TItem>;
}
| {
id: string;
ok: false;
error: string;
};
self.addEventListener(
"message",
async (event: MessageEvent<CursorWorkerRequestMessage>) => {
const { id, request } = event.data;
try {
const response = await fetchAllCursorPagesMainThread(request);
self.postMessage({
id,
ok: true,
response,
} satisfies CursorWorkerResponseMessage<unknown>);
} catch (error) {
self.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
} satisfies CursorWorkerResponseMessage<unknown>);
}
},
);

View File

@@ -0,0 +1,107 @@
export type CursorPageResponse<TItem> = {
items: TItem[];
limit?: number;
offset?: number;
total?: number;
cursor?: string;
nextCursor?: string;
next_cursor?: string;
};
export type CursorFetchParams = Record<
string,
string | number | boolean | null | undefined
>;
export type CursorFetchRequest = {
baseUrl: string;
path: string;
pageSize?: number;
params?: CursorFetchParams;
headers?: Record<string, string>;
credentials?: RequestCredentials;
maxPages?: number;
};
function normalizeBaseUrl(baseUrl: string) {
const value = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return new URL(value, globalThis.location?.origin ?? "http://localhost");
}
function buildCursorFetchUrl(
request: Required<Pick<CursorFetchRequest, "baseUrl" | "path">> &
Pick<CursorFetchRequest, "params">,
pageSize: number,
cursor: string | undefined,
) {
const path = request.path.replace(/^\/+/, "");
const url = new URL(path, normalizeBaseUrl(request.baseUrl));
for (const [key, value] of Object.entries(request.params ?? {})) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
url.searchParams.set("limit", String(pageSize));
if (cursor) {
url.searchParams.set("cursor", cursor);
} else {
url.searchParams.delete("cursor");
}
return url;
}
function readNextCursor<TItem>(page: CursorPageResponse<TItem>) {
return page.nextCursor || page.next_cursor || undefined;
}
export async function fetchAllCursorPagesMainThread<TItem>({
pageSize = 100,
credentials = "same-origin",
maxPages = 1000,
...request
}: CursorFetchRequest): Promise<CursorPageResponse<TItem>> {
const items: TItem[] = [];
let cursor: string | undefined;
for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
const url = buildCursorFetchUrl(request, pageSize, cursor);
const response = await fetch(url, {
headers: request.headers,
credentials,
});
if (!response.ok) {
throw new Error(
`Cursor page request failed with status ${response.status}`,
);
}
const page = (await response.json()) as CursorPageResponse<TItem>;
items.push(...page.items);
const nextCursor = readNextCursor(page);
if (!nextCursor) {
return {
...page,
items,
limit: pageSize,
offset: 0,
total: items.length,
cursor,
nextCursor: undefined,
next_cursor: undefined,
};
}
if (nextCursor === cursor) {
throw new Error("Cursor page request returned the same next cursor");
}
cursor = nextCursor;
}
throw new Error(`Cursor page request exceeded ${maxPages} pages`);
}

View File

@@ -0,0 +1,6 @@
export {
type CursorFetchRequest,
type CursorPageResponse,
fetchAllCursorPages,
fetchAllCursorPagesMainThread,
} from "./cursorFetch";

View File

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

View File

@@ -0,0 +1,114 @@
export const DEFAULT_SESSION_RENEW_THRESHOLD_MS = 10 * 60 * 1000;
export const DEFAULT_SESSION_RENEW_THROTTLE_MS = 30 * 1000;
export const SESSION_EXPIRY_STORAGE_KEY = "baron_session_expiry_enabled";
type SessionExpiryReadableStorage = Pick<Storage, "getItem">;
type SessionExpiryWritableStorage = Pick<Storage, "setItem">;
export type SessionRenewDecisionParams = {
expiresAtSec?: number | null;
nowMs: number;
isEnabled: boolean;
isAuthenticated: boolean;
isLoading: boolean;
isRenewInFlight: boolean;
lastAttemptAtMs: number;
thresholdMs?: number;
throttleMs?: number;
};
export type SessionExpiryPreferenceParams = {
defaultEnabled?: boolean;
storage?: SessionExpiryReadableStorage | null;
};
export type DevelopmentSessionRedirectParams = {
appMode: string;
defaultEnabled?: boolean;
storage?: SessionExpiryReadableStorage | null;
};
function browserStorage() {
if (typeof window === "undefined") {
return null;
}
return window.localStorage;
}
export function readSessionExpiryEnabled({
defaultEnabled = true,
storage = browserStorage(),
}: SessionExpiryPreferenceParams = {}) {
const stored = storage?.getItem(SESSION_EXPIRY_STORAGE_KEY) ?? null;
return stored === null ? defaultEnabled : stored !== "false";
}
export function writeSessionExpiryEnabled(
isEnabled: boolean,
storage: SessionExpiryWritableStorage | null = browserStorage(),
) {
storage?.setItem(SESSION_EXPIRY_STORAGE_KEY, String(isEnabled));
}
export function shouldSuppressDevelopmentSessionRedirect({
appMode,
defaultEnabled = appMode !== "development",
storage = browserStorage(),
}: DevelopmentSessionRedirectParams) {
return (
appMode === "development" &&
!readSessionExpiryEnabled({ defaultEnabled, storage })
);
}
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";

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

View File

@@ -0,0 +1,206 @@
[msg.common]
loading_more = "Loading more logs..."
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"
[msg.common.audit]
empty = "No audit logs found."
end = "End of audit feed"
load_error = "Error loading logs: {{error}}"
loading = "Loading audit logs..."
[msg.common.audit.registry]
count = "{{count}} logs"
[msg.admin.audit]
subtitle = "View administrator activity history."
[msg.dev.audit]
subtitle = "View developer activity history within the current app scope."
[ui.common]
no_results = "No results to display."
apply = "Apply"
actions = "Actions"
add = "Add"
all = "All"
apply = "Apply"
admin_only = "Admin Only"
apply = "Apply"
approve = "Approve"
assign = "Assign"
back = "Back"
back_to_login = "Back to login"
cancel = "Cancel"
change_file = "Change File"
clear = "Clear"
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_csv = "Export CSV"
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"
load_more = "Load more"
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.audit]
load_more = "Load more"
title = "Audit Logs"
[ui.common.audit.copy]
actor_id = "Copy User ID"
target = "Copy Client ID"
[ui.common.audit.filters]
user_id = "Filter by User ID"
client_id = "Filter by Client ID"
action = "Filter by Action (e.g. ROTATE_SECRET)"
status_all = "All Status"
[ui.common.audit.details]
actor = "User ID"
actor_id = "User ID · {{value}}"
after = "After · {{value}}"
before = "Before · {{value}}"
device = "Device · {{value}}"
error = "Error · {{value}}"
event_id = "Event ID · {{value}}"
ip = "IP · {{value}}"
latency = "Latency · {{value}}"
method = "Method · {{value}}"
path = "Path · {{value}}"
request = "Request"
request_id = "Request ID · {{value}}"
result = "Result"
tenant = "Tenant · {{value}}"
target = "Client ID · {{value}}"
[ui.common.audit.registry]
title = "Audit registry"
[ui.common.audit.table]
no_logs = "No logs to display."
action = "Action"
actor = "User ID"
client_id = "Client ID"
user_id = "User ID"
status = "Status"
target = "Client ID"
time = "Time"
[ui.common.overview]
title = "Operational Status"
[ui.common.chart.period]
day = "Day"
month = "Month"
week = "Week"
[ui.common.chart.series_summary]
login_users = "Login {{login}} / Users {{subjects}}"
[ui.common.chart.axis]
x = "X-axis: Period"
y = "Y-axis: Login Requests"
[ui.admin.integrity]
fetch_error = "Unable to load the final integrity check result."
[ui.admin.integrity.summary]
failures_text = "Failures {{count}}"
title = "Final integrity check"
[ui.admin.integrity.section]
tenant_integrity = "Tenant integrity"
user_integrity = "User integrity"
[ui.admin.overview.chart]
description = "Check the graph by all or selected organizations."
title = "Login request status by company and app"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "Active"
blocked = "Blocked"
failure = "Failure"
inactive = "Inactive"
new = "New"
ok = "Ok"
pending = "Pending"
success = "Success"
unchanged = "Unchanged"
updated = "Updated"
[ui.common]
searching = "Searching..."
[ui.common.custom_claim_permission]
admin_only = "Admin only"
user_and_admin = "User and admin"

View File

@@ -0,0 +1,206 @@
[msg.common]
loading_more = "추가 로그를 불러오는 중..."
copied = "복사되었습니다."
error = "오류가 발생했습니다."
forbidden = "접근 권한이 없습니다."
loading = "로딩 중..."
no_results = "검색 결과가 없습니다."
no_description = "설명이 없습니다."
parsing = "데이터 파싱 중..."
requesting = "요청 중..."
saving = "저장 중..."
unknown_error = "알 수 없는 오류"
[msg.common.audit]
empty = "아직 수집된 감사 로그가 없습니다."
end = "End of audit feed"
load_error = "Error loading logs: {{error}}"
loading = "Loading audit logs..."
[msg.common.audit.registry]
count = "총 {{count}}개 로그"
[msg.admin.audit]
subtitle = "관리자 작업 이력을 조회합니다."
[msg.dev.audit]
subtitle = "현재 앱 범위의 개발자 작업 이력을 조회합니다."
[ui.common]
no_results = "표시할 결과가 없습니다."
apply = "적용"
actions = "액션"
add = "추가"
all = "전체"
apply = "적용"
admin_only = "관리자 전용"
apply = "적용"
approve = "승인"
assign = "할당"
back = "돌아가기"
back_to_login = "로그인으로 돌아가기"
cancel = "취소"
change_file = "파일 변경"
clear = "초기화"
clear_search = "검색 초기화"
close = "닫기"
collapse = "접기"
confirm = "확인"
continue = "계속 진행"
copy = "복사"
create = "생성"
delete = "삭제"
detail = "상세보기"
details = "상세정보"
disabled = "사용 안 함"
edit = "편집"
enabled = "사용"
export = "내보내기"
export_csv = "CSV 내보내기"
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 = "선택하세요"
load_more = "더 보기"
show_more = "+ 더보기"
language = "언어"
language_ko = "한국어"
language_en = "English"
submit = "신청하기"
submitting = "제출 중..."
success = "성공"
theme_dark = "Dark"
theme_light = "Light"
theme_toggle = "테마 전환"
unassigned = "미배정"
unknown = "Unknown"
[ui.common.audit]
load_more = "더 보기"
title = "감사 로그"
[ui.common.audit.copy]
actor_id = "사용자 ID 복사"
target = "클라이언트 ID 복사"
[ui.common.audit.filters]
user_id = "사용자 ID로 검색"
client_id = "클라이언트 ID로 검색"
action = "액션으로 검색 (예: ROTATE_SECRET)"
status_all = "전체 상태"
[ui.common.audit.details]
actor = "사용자 ID"
actor_id = "사용자 ID · {{value}}"
after = "After · {{value}}"
before = "Before · {{value}}"
device = "Device · {{value}}"
error = "Error · {{value}}"
event_id = "Event ID · {{value}}"
ip = "IP · {{value}}"
latency = "Latency · {{value}}"
method = "Method · {{value}}"
path = "Path · {{value}}"
request = "Request"
request_id = "Request ID · {{value}}"
result = "Result"
tenant = "Tenant · {{value}}"
target = "클라이언트 ID · {{value}}"
[ui.common.audit.registry]
title = "감사 로그 레지스트리"
[ui.common.audit.table]
no_logs = "표시할 로그가 없습니다."
action = "작업"
actor = "사용자 ID"
client_id = "클라이언트 ID"
user_id = "사용자 ID"
status = "상태"
target = "클라이언트 ID"
time = "시간"
[ui.common.overview]
title = "운영 현황"
[ui.common.chart.period]
day = "일"
month = "월"
week = "주"
[ui.common.chart.series_summary]
login_users = "로그인 {{login}} / 사용자 {{subjects}}"
[ui.common.chart.axis]
x = "X축: 기간"
y = "Y축: 로그인 요청 수"
[ui.admin.integrity]
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
[ui.admin.integrity.summary]
failures_text = "실패 {{count}}건"
title = "정합성 최종 검증"
[ui.admin.integrity.section]
tenant_integrity = "테넌트 정합성"
user_integrity = "사용자 정합성"
[ui.admin.overview.chart]
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
title = "회사별 앱별 로그인 요청 현황"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
new = "신규"
ok = "정상"
pending = "준비 중"
success = "성공"
unchanged = "동일"
updated = "수정"
[ui.common]
searching = "검색 중..."
[ui.common.custom_claim_permission]
admin_only = "관리자만 가능"
user_and_admin = "사용자와 관리자"

View File

@@ -0,0 +1,206 @@
[msg.common]
loading_more = ""
copied = ""
error = ""
forbidden = ""
loading = ""
no_results = ""
no_description = ""
parsing = ""
requesting = ""
saving = ""
unknown_error = ""
[msg.common.audit]
empty = ""
end = ""
load_error = ""
loading = ""
[msg.common.audit.registry]
count = ""
[msg.admin.audit]
subtitle = ""
[msg.dev.audit]
subtitle = ""
[ui.common]
no_results = ""
apply = "Apply"
actions = ""
add = ""
all = ""
apply = ""
admin_only = ""
apply = ""
approve = ""
assign = ""
back = ""
back_to_login = ""
cancel = ""
change_file = ""
clear = ""
clear_search = ""
close = ""
collapse = ""
confirm = ""
continue = ""
copy = ""
create = ""
delete = ""
detail = ""
details = ""
disabled = ""
edit = ""
enabled = ""
export = ""
export_csv = ""
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 = ""
load_more = ""
show_more = ""
language = ""
language_ko = ""
language_en = ""
submit = ""
submitting = ""
success = ""
theme_dark = ""
theme_light = ""
theme_toggle = ""
unassigned = ""
unknown = ""
[ui.common.audit]
load_more = ""
title = ""
[ui.common.audit.copy]
actor_id = ""
target = ""
[ui.common.audit.filters]
user_id = ""
client_id = ""
action = ""
status_all = ""
[ui.common.audit.details]
actor = ""
actor_id = ""
after = ""
before = ""
device = ""
error = ""
event_id = ""
ip = ""
latency = ""
method = ""
path = ""
request = ""
request_id = ""
result = ""
tenant = ""
target = ""
[ui.common.audit.registry]
title = ""
[ui.common.audit.table]
no_logs = ""
action = ""
actor = ""
client_id = ""
user_id = ""
status = ""
target = ""
time = ""
[ui.common.overview]
title = ""
[ui.common.chart.period]
day = ""
month = ""
week = ""
[ui.common.chart.series_summary]
login_users = ""
[ui.common.chart.axis]
x = ""
y = ""
[ui.admin.integrity]
fetch_error = ""
[ui.admin.integrity.summary]
failures_text = ""
title = ""
[ui.admin.integrity.section]
tenant_integrity = ""
user_integrity = ""
[ui.admin.overview.chart]
description = ""
title = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.status]
active = ""
blocked = ""
failure = ""
inactive = ""
new = ""
ok = ""
pending = ""
success = ""
unchanged = ""
updated = ""
[ui.common]
searching = ""
[ui.common.custom_claim_permission]
admin_only = ""
user_and_admin = ""

5507
baron-sso/common/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
{
"name": "baron-sso",
"private": true,
"scripts": {
"dev:all": "pnpm -r run dev",
"build:all": "pnpm -r run build",
"lint": "biome check .",
"lint:fix": "biome check . --write",
"lint:all": "pnpm -r run lint"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@playwright/test": "^1.58.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"vite": "^8.0.3",
"vitest": "^4.1.5"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.1.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^7.15.1",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"
}
}

4556
baron-sso/common/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
import { Menu, SquareMenu } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
import { shellLayoutClasses } from "./layout";
export type ShellSidebarNavItem = {
labelKey: string;
labelFallback: string;
to: string;
icon: ComponentType<{ size?: number | string }>;
isExternal?: boolean;
end?: boolean;
isActive?: boolean;
};
type ShellSidebarProps = {
brandLabel: string;
brandTitle: string;
brandIcon?: ReactNode;
navContent: ReactNode;
footerContent: ReactNode;
collapsed?: boolean;
onToggleCollapsed?: () => void;
collapseLabel?: string;
expandLabel?: string;
};
export function AppSidebar({
brandLabel,
brandTitle,
brandIcon,
navContent,
footerContent,
collapsed = false,
onToggleCollapsed,
collapseLabel = "Collapse sidebar",
expandLabel = "Expand sidebar",
}: ShellSidebarProps) {
return (
<aside
className={
collapsed ? shellLayoutClasses.asideCollapsed : shellLayoutClasses.aside
}
>
<div>
<div
className={
collapsed
? shellLayoutClasses.brandSectionCollapsed
: shellLayoutClasses.brandSection
}
>
<div
className={
collapsed
? shellLayoutClasses.brandWrapCollapsed
: shellLayoutClasses.brandWrap
}
>
{onToggleCollapsed ? (
<button
type="button"
onClick={onToggleCollapsed}
className="grid h-11 w-11 place-items-center rounded-xl border border-border bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)] transition hover:bg-primary/20"
aria-label={collapsed ? expandLabel : collapseLabel}
title={collapsed ? expandLabel : collapseLabel}
>
<span className="sr-only">
{collapsed ? expandLabel : collapseLabel}
</span>
{collapsed ? <Menu size={20} /> : <SquareMenu size={20} />}
</button>
) : (
<div
className={
collapsed
? shellLayoutClasses.brandIconCollapsed
: shellLayoutClasses.brandIcon
}
>
{brandIcon}
</div>
)}
<div className={collapsed ? "hidden" : "block"}>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{brandLabel}
</p>
<h1 className="text-lg font-semibold">{brandTitle}</h1>
</div>
</div>
</div>
<nav
className={
collapsed
? shellLayoutClasses.navWrapCollapsed
: shellLayoutClasses.navWrap
}
>
{navContent}
</nav>
</div>
<div>{footerContent}</div>
</aside>
);
}

View File

@@ -0,0 +1,147 @@
import {
readSessionExpiryEnabled,
SESSION_EXPIRY_STORAGE_KEY,
writeSessionExpiryEnabled,
} from "../core/session";
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 = SESSION_EXPIRY_STORAGE_KEY;
export const SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY =
"baron_shell_sidebar_collapsed";
export type { ShellSidebarNavItem } from "./AppSidebar";
export { AppSidebar } from "./AppSidebar";
export { shellLayoutClasses } from "./layout";
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(defaultEnabled = true) {
return readSessionExpiryEnabled({ defaultEnabled });
}
export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
writeSessionExpiryEnabled(isEnabled);
}
export function readShellSidebarCollapsed(defaultCollapsed = false) {
const stored = window.localStorage.getItem(
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
);
if (stored === null) {
return defaultCollapsed;
}
return stored === "true";
}
export function writeShellSidebarCollapsed(isCollapsed: boolean) {
window.localStorage.setItem(
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
String(isCollapsed),
);
}
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.shell.session.active", "세션 활성");
if (remainingMs === null) {
toneClass = "border-border bg-card text-muted-foreground";
text = t("ui.shell.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.shell.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.shell.session.expiring",
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
},
);
} else {
text = t(
"ui.shell.session.remaining",
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
},
);
}
return {
toneClass,
text,
};
}

View File

@@ -0,0 +1,66 @@
export const shellLayoutClasses = {
root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground",
rootCollapsed:
"grid min-h-screen grid-cols-[80px,minmax(0,1fr)] bg-background text-foreground",
aside:
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
asideCollapsed:
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
asideStatic:
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
brandSection:
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
brandSectionCollapsed:
"flex items-center justify-between px-3 py-4 md:block md:space-y-4 md:px-2 md:py-6",
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
brandWrapCollapsed: "flex items-center gap-3 md:flex-col md:items-center",
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)]",
brandIconCollapsed:
"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",
navWrapCollapsed: "px-2 pb-4 md:px-2 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",
navItemBaseCollapsed:
"flex items-center justify-center gap-0 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",
logoutButtonCollapsed:
"flex w-full items-center justify-center gap-0 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",
headerActionsCollapsed: "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",
sidebarToggleButton:
"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;

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

View File

@@ -0,0 +1,68 @@
import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
import animatePlugin from "tailwindcss-animate";
const commonPreset: Config = {
darkMode: ["class"],
content: [], // Content should be defined by the consuming app
theme: {
container: {
center: true,
padding: "1.5rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["Space Grotesk", "Pretendard Variable", ...fontFamily.sans],
},
boxShadow: {
card: "0 12px 40px rgba(7, 15, 26, 0.25)",
},
},
},
plugins: [animatePlugin],
};
export default commonPreset;

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@common/*": ["./*"]
}
}
}

View File

@@ -0,0 +1,28 @@
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",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
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(" ");
}

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

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

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

@@ -0,0 +1,44 @@
import type { HTMLAttributes, ReactNode } from "react";
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
export const commonSearchFilterBarRowClass =
"flex flex-col gap-3 md:flex-row md:items-center md:justify-between";
export const commonSearchFilterBarPrimaryClass =
"flex min-w-0 flex-1 flex-col gap-3 md:flex-row md:items-center";
export const commonSearchFilterBarActionsClass =
"flex flex-wrap items-center gap-2";
export const commonSearchFilterBarAdvancedClass =
"flex flex-col gap-4 rounded-lg border border-border/40 bg-secondary/30 p-4 animate-in fade-in slide-in-from-top-2 duration-200";
type SearchFilterBarProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
actions?: ReactNode;
advanced?: ReactNode;
advancedOpen?: boolean;
primary: ReactNode;
};
export function SearchFilterBar({
actions,
advanced,
advancedOpen = false,
className,
primary,
...props
}: SearchFilterBarProps) {
return (
<div className={cx("space-y-3", className)} {...props}>
<div className={commonSearchFilterBarRowClass}>
<div className={commonSearchFilterBarPrimaryClass}>{primary}</div>
{actions ? (
<div className={commonSearchFilterBarActionsClass}>{actions}</div>
) : null}
</div>
{advanced && advancedOpen ? (
<div className={commonSearchFilterBarAdvancedClass}>{advanced}</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,18 @@
export const commonTableWrapperClass = "relative w-full";
export const commonTableClass = "w-full caption-bottom text-sm";
export const commonTableHeaderClass = "[&_tr]:border-b";
export const commonTableHeaderSurfaceClass = "bg-secondary shadow-sm";
export const commonStickyTableHeaderClass =
"sticky top-0 z-10 bg-secondary shadow-sm";
export const commonTableBodyClass = "[&_tr:last-child]:border-0";
export const commonTableFooterClass = "bg-muted/50 font-medium text-foreground";
export const commonTableRowClass =
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted";
export const commonTableHeadClass =
"h-12 px-6 text-left text-xs font-sans font-bold uppercase tracking-[0.08em] text-foreground align-middle";
export const commonTableCellClass = "p-6 align-middle text-sm";
export const commonTableCaptionClass = "mt-4 text-sm text-muted-foreground";
export const commonTableShellClass =
"flex-1 rounded-md border overflow-hidden flex flex-col";
export const commonTableViewportClass =
"flex-1 overflow-auto relative custom-scrollbar";