첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
1
baron-sso/common/.npmrc
Normal file
1
baron-sso/common/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
confirmModulesPurge=false
|
||||
3
baron-sso/common/biome.json
Normal file
3
baron-sso/common/biome.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["./config/biome.base.json"]
|
||||
}
|
||||
36
baron-sso/common/config/biome.base.json
Normal file
36
baron-sso/common/config/biome.base.json
Normal 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/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
67
baron-sso/common/config/vite.base.ts
Normal file
67
baron-sso/common/config/vite.base.ts
Normal 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);
|
||||
92
baron-sso/common/core/audit/index.ts
Normal file
92
baron-sso/common/core/audit/index.ts
Normal 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 || "-";
|
||||
}
|
||||
87
baron-sso/common/core/auth/index.ts
Normal file
87
baron-sso/common/core/auth/index.ts
Normal 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;
|
||||
}
|
||||
148
baron-sso/common/core/components/audit/AuditLogTable.test.tsx
Normal file
148
baron-sso/common/core/components/audit/AuditLogTable.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
409
baron-sso/common/core/components/audit/AuditLogTable.tsx
Normal file
409
baron-sso/common/core/components/audit/AuditLogTable.tsx
Normal 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>;
|
||||
}
|
||||
1
baron-sso/common/core/components/audit/index.ts
Normal file
1
baron-sso/common/core/components/audit/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./AuditLogTable";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
19
baron-sso/common/core/components/overview/OverviewMetric.tsx
Normal file
19
baron-sso/common/core/components/overview/OverviewMetric.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
3
baron-sso/common/core/components/overview/index.ts
Normal file
3
baron-sso/common/core/components/overview/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { OverviewAxisNotes } from "./OverviewAxisNotes";
|
||||
export { OverviewMetric } from "./OverviewMetric";
|
||||
export { OverviewSelectionChips } from "./OverviewSelectionChips";
|
||||
68
baron-sso/common/core/components/page/PageHeader.tsx
Normal file
68
baron-sso/common/core/components/page/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
baron-sso/common/core/components/page/index.ts
Normal file
1
baron-sso/common/core/components/page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./PageHeader";
|
||||
170
baron-sso/common/core/components/sort/SortableTableHead.tsx
Normal file
170
baron-sso/common/core/components/sort/SortableTableHead.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
baron-sso/common/core/components/sort/index.ts
Normal file
1
baron-sso/common/core/components/sort/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./SortableTableHead";
|
||||
11
baron-sso/common/core/i18n/index.ts
Normal file
11
baron-sso/common/core/i18n/index.ts
Normal 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";
|
||||
182
baron-sso/common/core/i18n/loader.ts
Normal file
182
baron-sso/common/core/i18n/loader.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
20
baron-sso/common/core/i18n/types.ts
Normal file
20
baron-sso/common/core/i18n/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const LOCALE_STORAGE_KEY = "locale";
|
||||
export const DEFAULT_LOCALE = "ko";
|
||||
export const SUPPORTED_LOCALES = ["ko", "en"] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export type TomlValue = string | TomlObject;
|
||||
|
||||
export interface TomlObject {
|
||||
[key: string]: TomlValue;
|
||||
}
|
||||
|
||||
export interface TranslatorOptions {
|
||||
normalizeEscapedNewlines?: boolean;
|
||||
}
|
||||
|
||||
export interface TranslatorInput {
|
||||
en: string[];
|
||||
ko: string[];
|
||||
}
|
||||
85
baron-sso/common/core/pagination/cursorFetch.ts
Normal file
85
baron-sso/common/core/pagination/cursorFetch.ts
Normal 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";
|
||||
44
baron-sso/common/core/pagination/cursorFetch.worker.ts
Normal file
44
baron-sso/common/core/pagination/cursorFetch.worker.ts
Normal 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>);
|
||||
}
|
||||
},
|
||||
);
|
||||
107
baron-sso/common/core/pagination/cursorFetchCore.ts
Normal file
107
baron-sso/common/core/pagination/cursorFetchCore.ts
Normal 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`);
|
||||
}
|
||||
6
baron-sso/common/core/pagination/index.ts
Normal file
6
baron-sso/common/core/pagination/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type CursorFetchRequest,
|
||||
type CursorPageResponse,
|
||||
fetchAllCursorPages,
|
||||
fetchAllCursorPagesMainThread,
|
||||
} from "./cursorFetch";
|
||||
7
baron-sso/common/core/query/queryClient.ts
Normal file
7
baron-sso/common/core/query/queryClient.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const queryClientDefaultOptions = {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
} as const;
|
||||
114
baron-sso/common/core/session/index.ts
Normal file
114
baron-sso/common/core/session/index.ts
Normal 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);
|
||||
}
|
||||
8
baron-sso/common/core/utils/index.ts
Normal file
8
baron-sso/common/core/utils/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function mergeClassNames(
|
||||
mergeFn: (...classNames: string[]) => string,
|
||||
classNames: string[],
|
||||
) {
|
||||
return mergeFn(...classNames);
|
||||
}
|
||||
|
||||
export * from "./sort";
|
||||
97
baron-sso/common/core/utils/sort.ts
Normal file
97
baron-sso/common/core/utils/sort.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export type SortConfig<Key extends string = string> = {
|
||||
key: Key;
|
||||
direction: SortDirection;
|
||||
};
|
||||
|
||||
export type SortableValue = string | number | boolean | Date | null | undefined;
|
||||
|
||||
export type SortResolver<T> = (item: T) => SortableValue;
|
||||
|
||||
export type SortResolverMap<T, Key extends string = string> = Partial<
|
||||
Record<Key, SortResolver<T>>
|
||||
>;
|
||||
|
||||
function normalizeSortableValue(value: SortableValue) {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.toLocaleLowerCase();
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function compareNullableValues(
|
||||
left: SortableValue,
|
||||
right: SortableValue,
|
||||
direction: SortDirection,
|
||||
) {
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (left === null || left === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (right === null || right === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const normalizedLeft = normalizeSortableValue(left);
|
||||
const normalizedRight = normalizeSortableValue(right);
|
||||
|
||||
if (normalizedLeft === normalizedRight) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (normalizedLeft === null || normalizedLeft === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (normalizedRight === null || normalizedRight === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const comparison = normalizedLeft < normalizedRight ? -1 : 1;
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
}
|
||||
|
||||
export function toggleSort<Key extends string>(
|
||||
current: SortConfig<Key> | null,
|
||||
key: Key,
|
||||
): SortConfig<Key> {
|
||||
if (current?.key === key && current.direction === "asc") {
|
||||
return { key, direction: "desc" };
|
||||
}
|
||||
|
||||
return { key, direction: "asc" };
|
||||
}
|
||||
|
||||
export function sortItems<T, Key extends string = string>(
|
||||
items: T[],
|
||||
sortConfig: SortConfig<Key> | null,
|
||||
resolverMap: SortResolverMap<T, Key> = {},
|
||||
) {
|
||||
if (!sortConfig) {
|
||||
return [...items];
|
||||
}
|
||||
|
||||
const resolveValue =
|
||||
resolverMap[sortConfig.key] ??
|
||||
((item: T) =>
|
||||
(item as Record<string, SortableValue>)[sortConfig.key] ?? null);
|
||||
|
||||
return [...items].sort((left, right) =>
|
||||
compareNullableValues(
|
||||
resolveValue(left),
|
||||
resolveValue(right),
|
||||
sortConfig.direction,
|
||||
),
|
||||
);
|
||||
}
|
||||
206
baron-sso/common/locales/en.toml
Normal file
206
baron-sso/common/locales/en.toml
Normal 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"
|
||||
206
baron-sso/common/locales/ko.toml
Normal file
206
baron-sso/common/locales/ko.toml
Normal 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 = "사용자와 관리자"
|
||||
206
baron-sso/common/locales/template.toml
Normal file
206
baron-sso/common/locales/template.toml
Normal 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
5507
baron-sso/common/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
baron-sso/common/package.json
Normal file
50
baron-sso/common/package.json
Normal 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
4556
baron-sso/common/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
105
baron-sso/common/shell/AppSidebar.tsx
Normal file
105
baron-sso/common/shell/AppSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
baron-sso/common/shell/index.ts
Normal file
147
baron-sso/common/shell/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
baron-sso/common/shell/layout.ts
Normal file
66
baron-sso/common/shell/layout.ts
Normal 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;
|
||||
64
baron-sso/common/theme/base.css
Normal file
64
baron-sso/common/theme/base.css
Normal file
@@ -0,0 +1,64 @@
|
||||
@layer base {
|
||||
:root.light {
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 223 25% 12%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 223 25% 12%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 223 25% 12%;
|
||||
--primary: 209 79% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 17% 94%;
|
||||
--secondary-foreground: 223 25% 20%;
|
||||
--muted: 223 15% 45%;
|
||||
--muted-foreground: 223 15% 45%;
|
||||
--accent: 40 96% 62%;
|
||||
--accent-foreground: 223 25% 12%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 17% 90%;
|
||||
--input: 220 17% 90%;
|
||||
--ring: 209 79% 52%;
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--background: 210 25% 6%;
|
||||
--foreground: 210 35% 96%;
|
||||
--card: 215 32% 9%;
|
||||
--card-foreground: 210 35% 96%;
|
||||
--popover: 215 32% 9%;
|
||||
--popover-foreground: 210 35% 96%;
|
||||
--primary: 209 79% 52%;
|
||||
--primary-foreground: 210 35% 96%;
|
||||
--secondary: 215 25% 16%;
|
||||
--secondary-foreground: 210 35% 96%;
|
||||
--muted: 215 15% 65%;
|
||||
--muted-foreground: 215 15% 65%;
|
||||
--accent: 42 95% 57%;
|
||||
--accent-foreground: 215 25% 10%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 35% 96%;
|
||||
--border: 215 25% 24%;
|
||||
--input: 215 25% 24%;
|
||||
--ring: 209 79% 52%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-background font-sans text-foreground antialiased;
|
||||
background-image: var(--app-background-image, none);
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-inherit no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-panel {
|
||||
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
|
||||
}
|
||||
}
|
||||
68
baron-sso/common/theme/tailwind.preset.ts
Normal file
68
baron-sso/common/theme/tailwind.preset.ts
Normal 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;
|
||||
8
baron-sso/common/tsconfig.base.json
Normal file
8
baron-sso/common/tsconfig.base.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@common/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
28
baron-sso/common/ui/badge.ts
Normal file
28
baron-sso/common/ui/badge.ts
Normal 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(" ");
|
||||
}
|
||||
37
baron-sso/common/ui/button.ts
Normal file
37
baron-sso/common/ui/button.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const commonButtonBaseClass =
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background";
|
||||
|
||||
export const commonButtonVariantClasses = {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
} as const;
|
||||
|
||||
export const commonButtonSizeClasses = {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-6 text-base",
|
||||
icon: "h-10 w-10",
|
||||
} as const;
|
||||
|
||||
export type CommonButtonVariant = keyof typeof commonButtonVariantClasses;
|
||||
export type CommonButtonSize = keyof typeof commonButtonSizeClasses;
|
||||
|
||||
export function getCommonButtonClasses({
|
||||
variant = "default",
|
||||
size = "default",
|
||||
}: {
|
||||
variant?: CommonButtonVariant;
|
||||
size?: CommonButtonSize;
|
||||
}) {
|
||||
return [
|
||||
commonButtonBaseClass,
|
||||
commonButtonVariantClasses[variant],
|
||||
commonButtonSizeClasses[size],
|
||||
].join(" ");
|
||||
}
|
||||
7
baron-sso/common/ui/card.ts
Normal file
7
baron-sso/common/ui/card.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const commonCardClass =
|
||||
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card";
|
||||
export const commonCardHeaderClass = "flex flex-col space-y-1.5 p-6";
|
||||
export const commonCardTitleClass = "text-lg font-semibold leading-none";
|
||||
export const commonCardDescriptionClass = "text-sm text-muted-foreground";
|
||||
export const commonCardContentClass = "p-6 pt-0";
|
||||
export const commonCardFooterClass = "flex items-center p-6 pt-0";
|
||||
2
baron-sso/common/ui/input.ts
Normal file
2
baron-sso/common/ui/input.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const commonInputClass =
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
44
baron-sso/common/ui/search-filter-bar.tsx
Normal file
44
baron-sso/common/ui/search-filter-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
baron-sso/common/ui/table.ts
Normal file
18
baron-sso/common/ui/table.ts
Normal 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";
|
||||
Reference in New Issue
Block a user