forked from baron/baron-sso
패키징 개선
This commit is contained in:
@@ -76,6 +76,40 @@ describe("TenantProfilePage initial profile loading", () => {
|
||||
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
|
||||
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
|
||||
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
|
||||
expect(fetchAllTenantsMock).not.toHaveBeenCalled();
|
||||
expect(fetchAllTenantsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the persisted parent tenant label even when org config already exists", async () => {
|
||||
fetchAllTenantsMock.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-company",
|
||||
type: "ORGANIZATION",
|
||||
name: "인프라솔루션",
|
||||
slug: "infra-solution",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: [],
|
||||
parentId: "tenant-root",
|
||||
memberCount: 0,
|
||||
config: {},
|
||||
createdAt: "2026-06-17T00:00:00Z",
|
||||
updatedAt: "2026-06-17T00:00:00Z",
|
||||
},
|
||||
],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants/:tenantId" element={<TenantProfilePage />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/인프라솔루션 · infra-solution/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ export function TenantProfilePage() {
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
enabled: !!tenantQuery.data && !hasPersistedOrgConfig,
|
||||
enabled: !!tenantQuery.data,
|
||||
});
|
||||
const allTenants = parentQuery.data?.items ?? [];
|
||||
const orgConfigCandidate = tenantQuery.data
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
baron: true,
|
||||
baronOrg: true,
|
||||
worksmobileId: false,
|
||||
externalKey: false,
|
||||
externalKey: true,
|
||||
worksmobileDomain: true,
|
||||
worksmobile: true,
|
||||
worksmobileOrg: true,
|
||||
|
||||
@@ -29,6 +29,13 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -47,6 +54,7 @@ import {
|
||||
fetchMe,
|
||||
fetchWorksmobileComparison,
|
||||
fetchWorksmobileOverview,
|
||||
importWorksmobileUsersFromWorks,
|
||||
retryWorksmobileJob,
|
||||
type WorksmobileComparisonItem,
|
||||
type WorksmobileOutboxItem,
|
||||
@@ -76,7 +84,7 @@ import {
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedCreateUserIds,
|
||||
getWorksmobileSelectedUpdateUserIds,
|
||||
getWorksmobileSelectedImportUserIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
summarizeWorksmobileComparison,
|
||||
type WorksmobileAccountStatusFilter,
|
||||
@@ -272,6 +280,7 @@ export function TenantWorksmobilePage() {
|
||||
overviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("조직 Sync 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
@@ -285,6 +294,7 @@ export function TenantWorksmobilePage() {
|
||||
overviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("구성원 Sync 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
@@ -296,12 +306,15 @@ export function TenantWorksmobilePage() {
|
||||
resourceKind,
|
||||
ids,
|
||||
initialPassword,
|
||||
initialPasswordUserIds,
|
||||
}: {
|
||||
resourceKind: "users" | "groups";
|
||||
ids: string[];
|
||||
initialPassword?: string;
|
||||
initialPasswordUserIds?: string[];
|
||||
}) => {
|
||||
const trimmedInitialPassword = initialPassword?.trim();
|
||||
const passwordUserIdSet = new Set(initialPasswordUserIds ?? []);
|
||||
const failures: string[] = [];
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
@@ -311,7 +324,7 @@ export function TenantWorksmobilePage() {
|
||||
tenantId,
|
||||
id,
|
||||
undefined,
|
||||
trimmedInitialPassword,
|
||||
passwordUserIdSet.has(id) ? trimmedInitialPassword : undefined,
|
||||
);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
@@ -355,12 +368,56 @@ export function TenantWorksmobilePage() {
|
||||
comparisonQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("WORKS 생성 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const importSelectedUsersMutation = useMutation({
|
||||
mutationFn: async (worksmobileUserIds: string[]) =>
|
||||
importWorksmobileUsersFromWorks(tenantId, worksmobileUserIds),
|
||||
onSuccess: (result) => {
|
||||
setSelectedUserRowKeys([]);
|
||||
const failureCount = result.failures?.length ?? 0;
|
||||
const description = [
|
||||
`Baron 업데이트 ${result.updatedCount}건`,
|
||||
`Baron 생성 ${result.createdCount}건`,
|
||||
`external_key 반영 ${result.externalKeyUpdates}건`,
|
||||
failureCount > 0 ? `실패 ${failureCount}건` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (failureCount > 0) {
|
||||
toast.error("일부 Works정보 가져오기 실패", {
|
||||
description:
|
||||
result.failures
|
||||
?.slice(0, 3)
|
||||
.map((failure) =>
|
||||
[
|
||||
failure.email ?? failure.worksmobileId ?? "unknown",
|
||||
failure.error,
|
||||
].join(": "),
|
||||
)
|
||||
.join("\n") ?? description,
|
||||
});
|
||||
} else {
|
||||
toast.success("Works정보 가져오기를 완료했습니다.", {
|
||||
description,
|
||||
});
|
||||
}
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("Works정보 가져오기 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const syncSelectedOrgUnitsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
baronIds,
|
||||
@@ -389,6 +446,7 @@ export function TenantWorksmobilePage() {
|
||||
comparisonQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
overviewQuery.refetch();
|
||||
toast.error("선택 조직 동기화 작업 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
@@ -561,6 +619,7 @@ export function TenantWorksmobilePage() {
|
||||
<TableHead>작업</TableHead>
|
||||
<TableHead>변경 요약</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>오류</TableHead>
|
||||
<TableHead>retry</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
@@ -605,7 +664,29 @@ export function TenantWorksmobilePage() {
|
||||
</details>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{job.status}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
job.status === "failed" ? "destructive" : "outline"
|
||||
}
|
||||
>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm">
|
||||
{job.lastError ? (
|
||||
<span
|
||||
className="line-clamp-3 text-xs text-destructive"
|
||||
title={job.lastError}
|
||||
>
|
||||
{job.lastError}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{job.retryCount}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
@@ -668,21 +749,24 @@ export function TenantWorksmobilePage() {
|
||||
visibleColumns={userVisibleColumns}
|
||||
onVisibleColumnsChange={setUserVisibleColumns}
|
||||
passwordManageTenantId={overview?.config.adminTenantId}
|
||||
actionLabel="선택 구성원 WORKS에 생성"
|
||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||
updateActionLabel="선택 구성원 업데이트 적용"
|
||||
onCreateSelected={(ids, initialPassword) =>
|
||||
actionLabel="Works에 정보 넣기"
|
||||
actionDisabled={
|
||||
isCreatingUsers ||
|
||||
createSelectedMutation.isPending ||
|
||||
importSelectedUsersMutation.isPending
|
||||
}
|
||||
importActionLabel="Works정보 가져오기"
|
||||
importActionDisabled={importSelectedUsersMutation.isPending}
|
||||
onCreateSelected={(ids, initialPassword, initialPasswordUserIds) =>
|
||||
createSelectedMutation.mutateAsync({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
initialPassword,
|
||||
initialPasswordUserIds,
|
||||
})
|
||||
}
|
||||
onUpdateSelected={(ids) =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "users",
|
||||
ids,
|
||||
})
|
||||
onImportSelected={(ids) =>
|
||||
importSelectedUsersMutation.mutate(ids)
|
||||
}
|
||||
requireInitialPassword
|
||||
/>
|
||||
@@ -1015,10 +1099,11 @@ function ComparisonTable({
|
||||
showBaronIdColumn = true,
|
||||
showManageColumn = true,
|
||||
actionLabel,
|
||||
updateActionLabel,
|
||||
importActionLabel,
|
||||
importActionDisabled = false,
|
||||
actionDisabled,
|
||||
onCreateSelected,
|
||||
onUpdateSelected,
|
||||
onImportSelected,
|
||||
onRunSelected,
|
||||
deleteActionLabel,
|
||||
deleteActionDisabled = false,
|
||||
@@ -1051,10 +1136,15 @@ function ComparisonTable({
|
||||
showBaronIdColumn?: boolean;
|
||||
showManageColumn?: boolean;
|
||||
actionLabel: string;
|
||||
updateActionLabel?: string;
|
||||
importActionLabel?: string;
|
||||
importActionDisabled?: boolean;
|
||||
actionDisabled: boolean;
|
||||
onCreateSelected: (ids: string[], initialPassword?: string) => unknown;
|
||||
onUpdateSelected?: (ids: string[]) => void;
|
||||
onCreateSelected: (
|
||||
ids: string[],
|
||||
initialPassword?: string,
|
||||
initialPasswordUserIds?: string[],
|
||||
) => unknown;
|
||||
onImportSelected?: (worksmobileUserIds: string[]) => void;
|
||||
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
|
||||
deleteActionLabel?: string;
|
||||
deleteActionDisabled?: boolean;
|
||||
@@ -1066,6 +1156,7 @@ function ComparisonTable({
|
||||
const [initialPassword, setInitialPassword] = React.useState("");
|
||||
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
|
||||
React.useState<string[]>([]);
|
||||
const [pendingActionIds, setPendingActionIds] = React.useState<string[]>([]);
|
||||
const tableViewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const selectableKeys = rows
|
||||
.filter(canSelectWorksmobileRow)
|
||||
@@ -1076,7 +1167,7 @@ function ComparisonTable({
|
||||
rows,
|
||||
selectedKeys,
|
||||
);
|
||||
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds(
|
||||
const selectedImportUserIds = getWorksmobileSelectedImportUserIds(
|
||||
rows,
|
||||
selectedKeys,
|
||||
);
|
||||
@@ -1090,7 +1181,7 @@ function ComparisonTable({
|
||||
selectedActionIds.length === 0 &&
|
||||
selectedDeleteIds.length > 0 &&
|
||||
canRunDeleteAction;
|
||||
const canRunUserUpdateAction = Boolean(onUpdateSelected);
|
||||
const canRunUserImportAction = Boolean(onImportSelected);
|
||||
const selectedActionLabel = shouldRunDeleteAction
|
||||
? deleteActionLabel
|
||||
: actionLabel;
|
||||
@@ -1102,11 +1193,9 @@ function ComparisonTable({
|
||||
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
|
||||
: shouldRunDeleteAction
|
||||
? selectedDeleteIds.length === 0 || deleteActionDisabled
|
||||
: requireInitialPassword
|
||||
? selectedCreateUserIds.length === 0
|
||||
: selectedActionIds.length === 0) || actionDisabled;
|
||||
const updateActionDisabled =
|
||||
selectedUpdateUserIds.length === 0 || actionDisabled;
|
||||
: selectedActionIds.length === 0) || actionDisabled;
|
||||
const importActionButtonDisabled =
|
||||
selectedImportUserIds.length === 0 || importActionDisabled;
|
||||
const allSelectableSelected =
|
||||
selectableKeys.length > 0 &&
|
||||
selectableKeys.every((key) => selectedKeys.includes(key));
|
||||
@@ -1228,7 +1317,8 @@ function ComparisonTable({
|
||||
onDeleteSelected(selectedDeleteIds);
|
||||
return;
|
||||
}
|
||||
if (requireInitialPassword) {
|
||||
if (requireInitialPassword && selectedCreateUserIds.length > 0) {
|
||||
setPendingActionIds(selectedActionIds);
|
||||
setPendingInitialPasswordIds(selectedCreateUserIds);
|
||||
setInitialPassword("");
|
||||
setInitialPasswordOpen(true);
|
||||
@@ -1237,11 +1327,11 @@ function ComparisonTable({
|
||||
onCreateSelected(selectedActionIds);
|
||||
};
|
||||
|
||||
const runUpdateAction = () => {
|
||||
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) {
|
||||
const runImportAction = () => {
|
||||
if (!onImportSelected || selectedImportUserIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
onUpdateSelected(selectedUpdateUserIds);
|
||||
onImportSelected(selectedImportUserIds);
|
||||
};
|
||||
|
||||
const confirmInitialPassword = async () => {
|
||||
@@ -1251,13 +1341,18 @@ function ComparisonTable({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onCreateSelected(pendingInitialPasswordIds, password);
|
||||
await onCreateSelected(
|
||||
pendingActionIds,
|
||||
password,
|
||||
pendingInitialPasswordIds,
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setInitialPasswordOpen(false);
|
||||
setInitialPassword("");
|
||||
setPendingInitialPasswordIds([]);
|
||||
setPendingActionIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1300,27 +1395,28 @@ function ComparisonTable({
|
||||
}
|
||||
/>
|
||||
{accountStatusFilter && onAccountStatusFilterChange ? (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
role="tablist"
|
||||
aria-label="WORKS 계정 상태"
|
||||
<Select
|
||||
value={accountStatusFilter}
|
||||
onValueChange={(value) =>
|
||||
onAccountStatusFilterChange(
|
||||
value as WorksmobileAccountStatusFilter,
|
||||
)
|
||||
}
|
||||
>
|
||||
{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>
|
||||
<SelectTrigger
|
||||
className="h-9 w-[148px]"
|
||||
aria-label="WORKS 계정 상태"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{worksmobileAccountStatusFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-start gap-2 xl:justify-end">
|
||||
@@ -1380,15 +1476,15 @@ function ComparisonTable({
|
||||
>
|
||||
{selectedActionLabel}
|
||||
</Button>
|
||||
{canRunUserUpdateAction && (
|
||||
{canRunUserImportAction && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={runUpdateAction}
|
||||
disabled={updateActionDisabled}
|
||||
onClick={runImportAction}
|
||||
disabled={importActionButtonDisabled}
|
||||
>
|
||||
{updateActionLabel || "선택 구성원 업데이트 적용"}
|
||||
{importActionLabel || "Works정보 가져오기"}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog
|
||||
@@ -1398,6 +1494,7 @@ function ComparisonTable({
|
||||
if (!open) {
|
||||
setInitialPassword("");
|
||||
setPendingInitialPasswordIds([]);
|
||||
setPendingActionIds([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1437,7 +1534,7 @@ function ComparisonTable({
|
||||
onClick={confirmInitialPassword}
|
||||
disabled={actionDisabled}
|
||||
>
|
||||
생성 작업 등록
|
||||
작업 등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonC
|
||||
baron: true,
|
||||
baronOrg: true,
|
||||
worksmobileId: false,
|
||||
externalKey: false,
|
||||
externalKey: true,
|
||||
worksmobileDomain: true,
|
||||
worksmobile: true,
|
||||
worksmobileOrg: true,
|
||||
@@ -212,6 +212,24 @@ export function getWorksmobileSelectedUpdateUserIds(
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function getWorksmobileSelectedImportUserIds(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
selectedKeys: string[],
|
||||
) {
|
||||
const selected = new Set(selectedKeys);
|
||||
return rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.resourceType === "USER" &&
|
||||
(row.status === "needs_update" ||
|
||||
row.status === "missing_external_key" ||
|
||||
row.status === "missing_in_baron") &&
|
||||
selected.has(getWorksmobileRowSelectionKey(row)),
|
||||
)
|
||||
.map((row) => row.worksmobileId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
export function formatWorksmobileSelectionFailureDescription(
|
||||
successCount: number,
|
||||
failures: string[],
|
||||
|
||||
@@ -1005,6 +1005,23 @@ export type WorksmobileComparison = {
|
||||
groups: WorksmobileComparisonItem[];
|
||||
};
|
||||
|
||||
export type WorksmobileImportUsersResult = {
|
||||
updatedCount: number;
|
||||
createdCount: number;
|
||||
externalKeyUpdates: number;
|
||||
failures?: Array<{
|
||||
worksmobileId?: string;
|
||||
email?: string;
|
||||
error: string;
|
||||
}>;
|
||||
items?: Array<{
|
||||
worksmobileId?: string;
|
||||
baronId?: string;
|
||||
email?: string;
|
||||
action: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
@@ -1194,6 +1211,17 @@ export async function enqueueWorksmobileUserSync(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importWorksmobileUsersFromWorks(
|
||||
tenantId: string,
|
||||
worksmobileUserIds: string[],
|
||||
) {
|
||||
const { data } = await apiClient.post<WorksmobileImportUsersResult>(
|
||||
`/v1/admin/tenants/${tenantId}/worksmobile/users/import-from-works`,
|
||||
{ worksmobileUserIds },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetWorksmobileUserPassword(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
|
||||
Reference in New Issue
Block a user