1
0
forked from baron/baron-sso

패키징 개선

This commit is contained in:
2026-06-22 17:56:20 +09:00
parent 12d8d0e832
commit 9cbc9828e6
27 changed files with 1239 additions and 177 deletions

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -284,7 +284,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
externalKey: true,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,

View File

@@ -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>

View File

@@ -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[],

View File

@@ -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,