1
0
forked from baron/baron-sso

웍스 동기화 이력확인 기능추가

This commit is contained in:
2026-06-02 10:41:33 +09:00
parent 75f192fb24
commit 565ef6b685
11 changed files with 3986 additions and 10 deletions

View File

@@ -0,0 +1,23 @@
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e9]:
- heading "Baron SSO" [level=1] [ref=e10]
- paragraph [ref=e11]: Admin Control Plane
- generic [ref=e12]:
- generic [ref=e13]:
- heading "관리자 로그인" [level=3] [ref=e14]:
- img [ref=e15]
- text: 관리자 로그인
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 관리자 페이지에 접속합니다.
- generic [ref=e19]:
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
- img [ref=e21]
- text: SSO 계정으로 로그인
- img [ref=e23]
- paragraph [ref=e27]:
- text: 관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
- paragraph [ref=e32]:
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
- text: 시스템 관리자에게 문의하세요.

View File

@@ -0,0 +1,6 @@
- generic [ref=e1]:
- button "Enable accessibility" [ref=e2]
- main:
- heading "Baron SW Portal" [level=1]
- paragraph: Loading sign-in
- generic: Sign in

View File

@@ -54,6 +54,7 @@ import {
retryWorksmobileJob,
type WorksmobileComparisonItem,
type WorksmobileCredentialBatch,
type WorksmobileOutboxItem,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
@@ -93,6 +94,70 @@ export function createWorksmobileCredentialBatchId() {
return `worksmobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function worksmobileJobPayloadString(job: WorksmobileOutboxItem, key: string) {
const value = job.payload?.[key];
return typeof value === "string" ? value.trim() : "";
}
function worksmobileJobRequestSummary(job: WorksmobileOutboxItem) {
const summary = job.payload?.requestSummary;
if (!summary || typeof summary !== "object" || Array.isArray(summary)) {
return {};
}
return summary as Record<string, unknown>;
}
function worksmobileSummaryString(
summary: Record<string, unknown>,
key: string,
) {
const value = summary[key];
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return "";
}
function formatWorksmobileJobTarget(job: WorksmobileOutboxItem) {
const summary = worksmobileJobRequestSummary(job);
return (
worksmobileJobPayloadString(job, "displayName") ||
worksmobileSummaryString(summary, "displayName") ||
worksmobileSummaryString(summary, "orgUnitName") ||
worksmobileJobPayloadString(job, "name") ||
worksmobileJobPayloadString(job, "loginEmail") ||
worksmobileJobPayloadString(job, "email") ||
job.resourceId
);
}
function formatWorksmobileJobTargetSubtext(job: WorksmobileOutboxItem) {
const summary = worksmobileJobRequestSummary(job);
return (
worksmobileJobPayloadString(job, "loginEmail") ||
worksmobileSummaryString(summary, "email") ||
worksmobileJobPayloadString(job, "email") ||
worksmobileJobPayloadString(job, "externalKey") ||
worksmobileSummaryString(summary, "orgUnitExternalKey") ||
job.resourceId
);
}
function formatWorksmobileJobSummaryParts(job: WorksmobileOutboxItem) {
const summary = worksmobileJobRequestSummary(job);
const parts = [
worksmobileJobPayloadString(job, "primaryLeafOrgName"),
worksmobileJobPayloadString(job, "matchLocalPart"),
worksmobileSummaryString(summary, "parentOrgUnitId"),
worksmobileSummaryString(summary, "employeeNumber"),
worksmobileSummaryString(summary, "task"),
].filter(Boolean);
return Array.from(new Set(parts));
}
export function TenantWorksmobilePage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
@@ -643,9 +708,10 @@ export function TenantWorksmobilePage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>resource</TableHead>
<TableHead>action</TableHead>
<TableHead>status</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead>retry</TableHead>
<TableHead className="w-24" />
</TableRow>
@@ -654,9 +720,31 @@ export function TenantWorksmobilePage() {
{(overview?.recentJobs ?? []).map((job) => (
<TableRow key={job.id}>
<TableCell>
{job.resourceType}:{job.resourceId}
<div className="font-medium">
{formatWorksmobileJobTarget(job)}
</div>
<div className="text-xs text-muted-foreground">
{job.resourceType}:
{formatWorksmobileJobTargetSubtext(job)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{job.action}</Badge>
</TableCell>
<TableCell>
<div className="flex max-w-md flex-wrap gap-1">
{formatWorksmobileJobSummaryParts(job).map((part) => (
<Badge key={part} variant="secondary">
{part}
</Badge>
))}
{formatWorksmobileJobSummaryParts(job).length === 0 && (
<span className="text-xs text-muted-foreground">
{job.resourceId}
</span>
)}
</div>
</TableCell>
<TableCell>{job.action}</TableCell>
<TableCell>{job.status}</TableCell>
<TableCell>{job.retryCount}</TableCell>
<TableCell>

View File

@@ -775,6 +775,7 @@ export type WorksmobileOutboxItem = {
resourceType: string;
resourceId: string;
action: string;
payload?: Record<string, unknown>;
status: string;
retryCount: number;
lastError?: string;

View File

@@ -634,6 +634,35 @@ test.describe("Worksmobile tenant management", () => {
retryCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
payload: {
loginEmail: "changed-user@example.com",
displayName: "변경 사용자",
primaryLeafOrgName: "인재성장",
requestSummary: {
email: "changed-user@example.com",
displayName: "변경 사용자",
userExternalKey: "user-failed",
},
},
},
{
id: "job-org-auto",
resourceType: "ORGUNIT",
resourceId: "org-auto",
action: "UPSERT",
status: "processed",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "people-growth",
requestSummary: {
orgUnitName: "인재성장",
email: "people-growth@example.com",
orgUnitExternalKey: "org-auto",
parentOrgUnitId: "externalKey:parent-org",
},
},
},
],
},
@@ -764,8 +793,16 @@ test.describe("Worksmobile tenant management", () => {
await page.getByRole("button", { name: "구성원 Sync" }).click();
await expect.poll(() => requests).toContain("user-sync");
await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText(
"changed-user@example.com",
);
await expect(
page.getByRole("row", { name: /ORGUNIT:people-growth/ }),
).toContainText("people-growth@example.com");
await expect(page.getByText("externalKey:parent-org")).toBeVisible();
await page
.getByRole("row", { name: /USER:user-failed/ })
.getByRole("row", { name: /변경 사용자/ })
.getByRole("button")
.click();
await expect.poll(() => requests).toContain("retry");

View File

@@ -671,7 +671,8 @@ func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *te
redacted := redactWorksmobileOutboxPayloads(jobs)
require.Nil(t, redacted[0].Payload)
require.Equal(t, "tester@samaneng.com", redacted[0].Payload["loginEmail"])
require.NotContains(t, redacted[0].Payload, "initialPassword")
}
func TestCompareWorksmobileUsersHidesMatchedByDefault(t *testing.T) {

View File

@@ -180,13 +180,119 @@ func worksmobileDirectoryAuthConfigured() bool {
func redactWorksmobileOutboxPayloads(jobs []domain.WorksmobileOutbox) []domain.WorksmobileOutbox {
for i := range jobs {
if jobs[i].Payload != nil {
jobs[i].Payload = nil
}
jobs[i].Payload = safeWorksmobileOutboxPayload(jobs[i].Payload)
}
return jobs
}
func safeWorksmobileOutboxPayload(payload domain.JSONMap) domain.JSONMap {
if payload == nil {
return nil
}
safe := domain.JSONMap{}
for _, key := range []string{
"tenantRootId",
"loginEmail",
"displayName",
"primaryLeafOrgName",
"credentialBatchId",
"credentialOperation",
"credentialBatchCreatedAt",
"worksmobileId",
"externalKey",
"domainId",
"name",
"email",
"matchLocalPart",
"baronStatus",
} {
if value, ok := payload[key]; ok && safeWorksmobilePayloadValue(value) != nil {
safe[key] = value
}
}
if summary := safeWorksmobileRequestSummary(payload["request"]); len(summary) > 0 {
safe["requestSummary"] = summary
}
return safe
}
func safeWorksmobilePayloadValue(value any) any {
switch v := value.(type) {
case string:
if strings.TrimSpace(v) == "" {
return nil
}
return v
case nil:
return nil
default:
return value
}
}
func safeWorksmobileRequestSummary(request any) domain.JSONMap {
switch v := request.(type) {
case WorksmobileUserPayload:
summary := domain.JSONMap{}
safeSetWorksmobileSummary(summary, "email", v.Email)
safeSetWorksmobileSummary(summary, "displayName", v.UserName.LastName)
safeSetWorksmobileSummary(summary, "userExternalKey", v.UserExternalKey)
safeSetWorksmobileSummary(summary, "cellPhone", v.CellPhone)
safeSetWorksmobileSummary(summary, "employeeNumber", v.EmployeeNumber)
safeSetWorksmobileSummary(summary, "task", v.Task)
return summary
case WorksmobilePasswordResetPayload:
summary := domain.JSONMap{}
safeSetWorksmobileSummary(summary, "email", v.Email)
return summary
case WorksmobileOrgUnitPayload:
summary := domain.JSONMap{}
safeSetWorksmobileSummary(summary, "email", v.Email)
safeSetWorksmobileSummary(summary, "orgUnitName", v.OrgUnitName)
safeSetWorksmobileSummary(summary, "orgUnitExternalKey", v.OrgUnitExternalKey)
safeSetWorksmobileSummary(summary, "parentOrgUnitId", v.ParentOrgUnitID)
if v.DomainID > 0 {
summary["domainId"] = v.DomainID
}
return summary
case map[string]any:
return safeWorksmobileRequestSummaryFromMap(v)
case domain.JSONMap:
return safeWorksmobileRequestSummaryFromMap(map[string]any(v))
default:
return nil
}
}
func safeWorksmobileRequestSummaryFromMap(request map[string]any) domain.JSONMap {
summary := domain.JSONMap{}
for _, key := range []string{
"email",
"userExternalKey",
"cellPhone",
"employeeNumber",
"task",
"orgUnitName",
"orgUnitExternalKey",
"parentOrgUnitId",
"domainId",
} {
if value, ok := request[key]; ok && safeWorksmobilePayloadValue(value) != nil {
summary[key] = value
}
}
if userName, ok := request["userName"].(map[string]any); ok {
safeSetWorksmobileSummary(summary, "displayName", stringValue(userName["lastName"]))
}
return summary
}
func safeSetWorksmobileSummary(summary domain.JSONMap, key string, value string) {
if value = strings.TrimSpace(value); value != "" {
summary[key] = value
}
}
func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {

View File

@@ -415,6 +415,85 @@ func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink
require.Equal(t, "works-tenant-1", overview.Config.AdminTenantID)
}
func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *testing.T) {
root := domain.Tenant{
ID: "root-tenant",
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
outboxRepo := &fakeWorksmobileOutboxRepo{
recent: []domain.WorksmobileOutbox{
{
ID: "job-user-upsert",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{
"loginEmail": "changed@example.com",
"displayName": "변경 사용자",
"primaryLeafOrgName": "인재성장",
"initialPassword": "Secret123!",
"request": WorksmobileUserPayload{
Email: "changed@example.com",
UserExternalKey: "user-1",
UserName: WorksmobileUserName{LastName: "변경 사용자"},
PasswordConfig: WorksmobilePasswordConfig{Password: "Secret123!"},
},
},
},
{
ID: "job-org-upsert",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "org-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{
"matchLocalPart": "people-growth",
"request": WorksmobileOrgUnitPayload{
OrgUnitName: "인재성장",
Email: "people-growth@example.com",
OrgUnitExternalKey: "org-1",
ParentOrgUnitID: "externalKey:parent-1",
},
},
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{root.ID: root}},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
overview, err := service.GetTenantOverview(context.Background(), root.ID)
require.NoError(t, err)
require.Len(t, overview.RecentJobs, 2)
userPayload := overview.RecentJobs[0].Payload
require.Equal(t, "changed@example.com", userPayload["loginEmail"])
require.Equal(t, "변경 사용자", userPayload["displayName"])
require.Equal(t, "인재성장", userPayload["primaryLeafOrgName"])
require.NotContains(t, userPayload, "initialPassword")
require.NotContains(t, userPayload, "request")
require.Equal(t, domain.JSONMap{
"email": "changed@example.com",
"displayName": "변경 사용자",
"userExternalKey": "user-1",
}, userPayload["requestSummary"])
orgPayload := overview.RecentJobs[1].Payload
require.Equal(t, "people-growth", orgPayload["matchLocalPart"])
require.NotContains(t, orgPayload, "request")
require.Equal(t, domain.JSONMap{
"email": "people-growth@example.com",
"orgUnitName": "인재성장",
"orgUnitExternalKey": "org-1",
"parentOrgUnitId": "externalKey:parent-1",
}, orgPayload["requestSummary"])
}
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
parentID := "root-tenant"
root := domain.Tenant{

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

3635
orgfront/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff