forked from baron/baron-sso
kratos SSOT 재설계
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user