1
0
forked from baron/baron-sso

kratos SSOT 재설계

This commit is contained in:
2026-06-12 18:36:18 +09:00
parent b96c8100e0
commit 8e9d015443
39 changed files with 3960 additions and 501 deletions

View File

@@ -64,14 +64,6 @@ vi.mock("../../lib/adminApi", () => ({
total: 1,
})),
fetchOrySSOTSystemStatus: vi.fn(async () => ({
userProjection: {
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
},
identityCache: {
status: "ready",
redisReady: true,
@@ -158,8 +150,8 @@ describe("DataIntegrityPage", () => {
).toBeGreaterThan(0);
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("152")).toBeInTheDocument();
expect(screen.getByText("151")).toBeInTheDocument();
expect(screen.queryByText("Local users")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {

View File

@@ -15,14 +15,6 @@ let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchOrySSOTSystemStatus: vi.fn(async () => ({
userProjection: {
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
},
identityCache: {
status: "ready",
redisReady: true,
@@ -74,8 +66,9 @@ describe("UserProjectionPage", () => {
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("관측 identity")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(screen.getByText("151")).toBeInTheDocument();
expect(screen.queryByText("Local users")).not.toBeInTheDocument();
expect(screen.queryByText("Backend 사용자 read model")).not.toBeInTheDocument();
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});

View File

@@ -72,7 +72,6 @@ export function UserProjectionContent({
if (confirmed) flushMutation.mutate();
};
const projection = data?.userProjection;
const identityCache = data?.identityCache;
const header = (
@@ -146,79 +145,6 @@ export function UserProjectionContent({
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold">
{t(
"ui.admin.ory_ssot.projection_card.title",
"Backend user read model",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.projection_card.description",
"PostgreSQL read model status used by admin search and statistics.",
)}
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.loading", "Loading")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.status", "Status")}
</dt>
<dd className="mt-1">
<StatusBadge
ready={projection?.ready ?? false}
status={projection?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.local_users", "Local users")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{projection?.projectedUsers ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.summary.last_synced",
"Last read-model refresh",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(projection?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.updated_at", "Updated at")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(projection?.updatedAt)}
</dd>
</div>
</dl>
)}
{projection?.lastError ? (
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<span>{projection.lastError}</span>
</div>
) : null}
</section>
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div>

View File

@@ -10,6 +10,7 @@ import {
filterWorksmobileComparisonRowsBySearch,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
formatWorksmobileSelectionFailureDescription,
formatWorksmobileUpdateDetails,
getDefaultGroupComparisonFilters,
getDefaultUserComparisonFilters,
@@ -509,6 +510,48 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toEqual([rows[0]]);
});
it("filters users by WORKS account status", () => {
const rows = [
{
resourceType: "USER",
status: "matched",
baronId: "user-1",
worksmobileAccountStatus: "active",
},
{
resourceType: "USER",
status: "matched",
baronId: "user-2",
worksmobileAccountStatus: "suspended",
},
{
resourceType: "USER",
status: "matched",
baronId: "user-3",
worksmobileAccountStatus: "invited",
},
];
expect(
filterWorksmobileComparisonRows(
rows,
getDefaultUserComparisonFilters(),
false,
"suspended",
),
).toEqual([rows[1]]);
});
it("formats partial Worksmobile selection failures with detailed reasons", () => {
expect(
formatWorksmobileSelectionFailureDescription(1, [
"7e30daf6-f912-4306-befc-478feb7b74cc: target user tenant is excluded from Worksmobile sync",
]),
).toBe(
"성공 1건, 실패 1건\n7e30daf6-f912-4306-befc-478feb7b74cc: target user tenant is excluded from Worksmobile sync",
);
});
it("formats update details for changed organization rows", () => {
expect(
formatWorksmobileUpdateDetails({
@@ -529,6 +572,28 @@ describe("TenantWorksmobilePage comparison helpers", () => {
]);
});
it("formats update details for changed user phone and employee number", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
baronName: "강명진",
worksmobileName: "강명진",
baronEmail: "mjkang4@hanmaceng.co.kr",
worksmobileEmail: "mjkang4@hanmaceng.co.kr",
externalKey: "user-1",
baronPhone: "+821051583696",
worksmobilePhone: "+821099998888",
baronEmployeeNumber: "mjkang4",
worksmobileEmployeeNumber: "M17205",
}),
).toEqual([
"전화번호: +821099998888 -> +821051583696",
"사번: M17205 -> mjkang4",
]);
});
it("formats WORKS account name with level on one line", () => {
expect(
formatWorksmobilePersonName({

View File

@@ -66,6 +66,7 @@ import {
filterWorksmobileComparisonRowsBySearch,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
formatWorksmobileSelectionFailureDescription,
formatWorksmobileUpdateDetails,
getDefaultGroupComparisonFilters,
getDefaultUserComparisonFilters,
@@ -77,10 +78,12 @@ import {
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
summarizeWorksmobileComparison,
type WorksmobileAccountStatusFilter,
type WorksmobileComparisonColumnKey,
type WorksmobileComparisonColumnVisibility,
type WorksmobileComparisonFilter,
type WorksmobileComparisonSummary,
worksmobileAccountStatusFilterOptions,
} from "./worksmobileComparison";
function worksmobileJobPayloadString(job: WorksmobileOutboxItem, key: string) {
@@ -183,6 +186,8 @@ export function TenantWorksmobilePage() {
const [groupFilters, setGroupFilters] = React.useState<
WorksmobileComparisonFilter[]
>(getDefaultGroupComparisonFilters);
const [userAccountStatusFilter, setUserAccountStatusFilter] =
React.useState<WorksmobileAccountStatusFilter>("all");
const [includeUserMissingExternalKey, setIncludeUserMissingExternalKey] =
React.useState(false);
const [includeGroupMissingExternalKey, setIncludeGroupMissingExternalKey] =
@@ -323,10 +328,11 @@ export function TenantWorksmobilePage() {
return {
resourceKind,
count: successCount,
failures,
failureCount: failures.length,
};
},
onSuccess: ({ resourceKind, count, failureCount }) => {
onSuccess: ({ resourceKind, count, failureCount, failures }) => {
if (resourceKind === "users") {
setSelectedUserRowKeys([]);
} else {
@@ -334,7 +340,10 @@ export function TenantWorksmobilePage() {
}
if (failureCount > 0) {
toast.error("일부 WORKS 생성 작업 등록 실패", {
description: `성공 ${count}건, 실패 ${failureCount}`,
description: formatWorksmobileSelectionFailureDescription(
count,
failures,
),
});
} else {
toast.success("WORKS 생성 작업을 등록했습니다.", {
@@ -418,6 +427,7 @@ export function TenantWorksmobilePage() {
comparisonUsers,
userFilters,
includeUserMissingExternalKey,
userAccountStatusFilter,
),
userSearch,
);
@@ -643,6 +653,11 @@ export function TenantWorksmobilePage() {
setUserFilters(nextFilters);
setSelectedUserRowKeys([]);
}}
accountStatusFilter={userAccountStatusFilter}
onAccountStatusFilterChange={(nextStatus) => {
setUserAccountStatusFilter(nextStatus);
setSelectedUserRowKeys([]);
}}
baronOrgColumnLabel="대표 Baron 조직"
includeMissingExternalKey={includeUserMissingExternalKey}
onIncludeMissingExternalKeyChange={(checked) => {
@@ -988,6 +1003,8 @@ function ComparisonTable({
searchPlaceholder = "이름 또는 UUID 검색",
filters,
onFiltersChange,
accountStatusFilter,
onAccountStatusFilterChange,
baronOrgColumnLabel = "Baron 조직",
includeMissingExternalKey,
onIncludeMissingExternalKeyChange,
@@ -1018,6 +1035,10 @@ function ComparisonTable({
searchPlaceholder?: string;
filters?: WorksmobileComparisonFilter[];
onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void;
accountStatusFilter?: WorksmobileAccountStatusFilter;
onAccountStatusFilterChange?: (
status: WorksmobileAccountStatusFilter,
) => void;
baronOrgColumnLabel?: string;
includeMissingExternalKey?: boolean;
onIncludeMissingExternalKeyChange?: (checked: boolean) => void;
@@ -1277,6 +1298,29 @@ function ComparisonTable({
) : null
}
/>
{accountStatusFilter && onAccountStatusFilterChange ? (
<div
className="flex flex-wrap items-center gap-2"
role="tablist"
aria-label="WORKS 계정 상태"
>
{worksmobileAccountStatusFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
role="tab"
size="sm"
variant={
accountStatusFilter === option.value ? "default" : "outline"
}
aria-selected={accountStatusFilter === option.value}
onClick={() => onAccountStatusFilterChange(option.value)}
>
{option.label}
</Button>
))}
</div>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Dialog
@@ -1603,6 +1647,13 @@ function ComparisonTable({
>
{getWorksmobileComparisonStatusLabel(row.status)}
</Badge>
{row.worksmobileAccountStatus && (
<div className="mt-1">
<Badge variant="outline">
WORKS {row.worksmobileAccountStatus}
</Badge>
</div>
)}
{formatWorksmobileUpdateDetails(row).map((detail) => (
<div
key={detail}

View File

@@ -6,6 +6,14 @@ export type WorksmobileComparisonFilter =
| "needs_update"
| "matched";
export type WorksmobileAccountStatusFilter =
| "all"
| "active"
| "invited"
| "suspended"
| "inactive"
| "deleted";
export type WorksmobileComparisonSummary = {
total: number;
matched: number;
@@ -204,6 +212,22 @@ export function getWorksmobileSelectedUpdateUserIds(
.filter((id): id is string => Boolean(id));
}
export function formatWorksmobileSelectionFailureDescription(
successCount: number,
failures: string[],
) {
const summary = `성공 ${successCount}건, 실패 ${failures.length}`;
const visibleFailures = failures.slice(0, 3);
if (failures.length <= visibleFailures.length) {
return [summary, ...visibleFailures].join("\n");
}
return [
summary,
...visibleFailures,
`${failures.length - visibleFailures.length}건 실패`,
].join("\n");
}
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
@@ -251,6 +275,7 @@ const worksmobileComparisonSearchFields: Array<
"externalKey",
"worksmobileName",
"worksmobileEmail",
"worksmobileAccountStatus",
"worksmobileLevelId",
"worksmobileLevelName",
"worksmobileTask",
@@ -292,6 +317,7 @@ export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
onlyMissingExternalKey = false,
accountStatus: WorksmobileAccountStatusFilter = "all",
) {
const allowedStatuses = new Set(
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
@@ -302,7 +328,15 @@ export function filterWorksmobileComparisonRows(
}
allowedStatuses.add("missing_external_key");
}
return rows.filter((row) => allowedStatuses.has(row.status));
return rows.filter((row) => {
if (accountStatus !== "all") {
return row.worksmobileAccountStatus === accountStatus;
}
if (!allowedStatuses.has(row.status)) {
return false;
}
return true;
});
}
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
@@ -358,6 +392,22 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
}
const expectedPhone = row.baronPhone?.trim() ?? "";
const actualPhone = row.worksmobilePhone?.trim() ?? "";
if (expectedPhone && actualPhone && expectedPhone !== actualPhone) {
details.push(`전화번호: ${actualPhone} -> ${expectedPhone}`);
}
const expectedEmployeeNumber = row.baronEmployeeNumber?.trim() ?? "";
const actualEmployeeNumber = row.worksmobileEmployeeNumber?.trim() ?? "";
if (
expectedEmployeeNumber &&
actualEmployeeNumber &&
expectedEmployeeNumber !== actualEmployeeNumber
) {
details.push(
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
);
}
return details;
}
@@ -445,6 +495,18 @@ export const comparisonFilterOptions: Array<{
export const userFilterOptions = comparisonFilterOptions;
export const worksmobileAccountStatusFilterOptions: Array<{
value: WorksmobileAccountStatusFilter;
label: string;
}> = [
{ value: "all", label: "WORKS 전체" },
{ value: "active", label: "active" },
{ value: "invited", label: "invited" },
{ value: "suspended", label: "suspended" },
{ value: "inactive", label: "inactive" },
{ value: "deleted", label: "deleted" },
];
export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] {
return ["baron_only", "needs_update", "works_only"];
}

View File

@@ -159,6 +159,7 @@ export type UserProjectionStatus = {
export type IdentityCacheStatus = {
status: string;
redisReady: boolean;
mirrorVersion?: string;
observedCount: number;
keyCount: number;
lastRefreshedAt?: string;
@@ -167,7 +168,6 @@ export type IdentityCacheStatus = {
};
export type OrySSOTSystemStatus = {
userProjection: UserProjectionStatus;
identityCache: IdentityCacheStatus;
};
@@ -884,6 +884,8 @@ export type WorksmobileComparisonItem = {
baronSlug?: string;
baronName?: string;
baronEmail?: string;
baronPhone?: string;
baronEmployeeNumber?: string;
baronPrimaryOrgId?: string;
baronPrimaryOrgSlug?: string;
baronPrimaryOrgName?: string;
@@ -894,6 +896,9 @@ export type WorksmobileComparisonItem = {
externalKey?: string;
worksmobileName?: string;
worksmobileEmail?: string;
worksmobilePhone?: string;
worksmobileEmployeeNumber?: string;
worksmobileAccountStatus?: string;
worksmobileLevelId?: string;
worksmobileLevelName?: string;
worksmobileTask?: string;