forked from baron/baron-sso
웍스 동기화 이력확인 기능추가
This commit is contained in:
23
.playwright-mcp/page-2026-06-01T04-26-41-512Z.yml
Normal file
23
.playwright-mcp/page-2026-06-01T04-26-41-512Z.yml
Normal 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: 시스템 관리자에게 문의하세요.
|
||||
6
.playwright-mcp/page-2026-06-01T04-26-49-730Z.yml
Normal file
6
.playwright-mcp/page-2026-06-01T04-26-49-730Z.yml
Normal 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
|
||||
0
.playwright-mcp/page-2026-06-01T07-15-49-591Z.yml
Normal file
0
.playwright-mcp/page-2026-06-01T07-15-49-591Z.yml
Normal 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>
|
||||
|
||||
@@ -775,6 +775,7 @@ export type WorksmobileOutboxItem = {
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
action: string;
|
||||
payload?: Record<string, unknown>;
|
||||
status: string;
|
||||
retryCount: number;
|
||||
lastError?: string;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
BIN
docs/snapshots/worksmobile-change-log-2026-06-02.png
Normal file
BIN
docs/snapshots/worksmobile-change-log-2026-06-02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
3635
orgfront/pnpm-lock.yaml
generated
Normal file
3635
orgfront/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user