첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user