forked from baron/baron-sso
orgfront 버그 픽스
This commit is contained in:
@@ -143,6 +143,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
login_count: 3,
|
||||
},
|
||||
]),
|
||||
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({
|
||||
minLength: 12,
|
||||
lowercase: true,
|
||||
@@ -196,6 +197,7 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
worksmobileId: "works-user-1",
|
||||
worksmobileName: "Engineer User",
|
||||
worksmobileEmail: "engineer@example.com",
|
||||
worksmobileDomainId: 1001,
|
||||
worksmobilePrimaryOrgId: "works-org-1",
|
||||
worksmobilePrimaryOrgName: "기술연구팀",
|
||||
status: "matched",
|
||||
@@ -380,17 +382,19 @@ describe("adminfront large page coverage smoke", () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
|
||||
target: { value: "InitialPassword!1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
),
|
||||
);
|
||||
const credentialBatchId = vi.mocked(
|
||||
adminApi.enqueueWorksmobileUserSync,
|
||||
).mock.calls[0][2];
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -416,6 +420,10 @@ describe("adminfront large page coverage smoke", () => {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
|
||||
target: { value: "InitialPassword!1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
||||
@@ -424,21 +432,20 @@ describe("adminfront large page coverage smoke", () => {
|
||||
1,
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tenant-company",
|
||||
"user-3",
|
||||
expect.any(String),
|
||||
undefined,
|
||||
"InitialPassword!1",
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads or deletes Worksmobile credential batches from history", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
it("renders and retries Worksmobile jobs from history", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -450,45 +457,20 @@ describe("adminfront large page coverage smoke", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
|
||||
await screen.findByText("credential-batch-1");
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-pending 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.downloadWorksmobileInitialPasswordsCSV,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
);
|
||||
expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("failed")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 값 삭제",
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "" })[0]);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.deleteWorksmobileCredentialBatchPasswords,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"job-1",
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 실패 사유 보기",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("worksmobile api failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enqueues Worksmobile password reset as a credential batch", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
it("opens Worksmobile password management for matched users", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
@@ -504,17 +486,21 @@ describe("adminfront large page coverage smoke", () => {
|
||||
await screen.findAllByText("Engineer User");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Engineer User 비밀번호 재설정",
|
||||
name: "Engineer User 비밀번호 관리",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-1",
|
||||
expect.any(String),
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"https://auth.worksmobile.com/integrate/password/manage",
|
||||
),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
const [url] = openSpy.mock.calls[0] ?? [];
|
||||
const parsed = new URL(String(url));
|
||||
expect(parsed.searchParams.get("targetUserTenantId")).toBe("works-admin");
|
||||
expect(parsed.searchParams.get("targetUserDomainId")).toBe("1001");
|
||||
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,9 +149,13 @@ describe("DataIntegrityPage", () => {
|
||||
it("renders Ory SSOT cache management inside data integrity", async () => {
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Ory SSOT 시스템" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("tab", { name: "Ory SSOT 시스템" }),
|
||||
);
|
||||
|
||||
expect((await screen.findAllByText("Ory SSOT 시스템")).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
(await screen.findAllByText("Ory SSOT 시스템")).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("152")).toBeInTheDocument();
|
||||
|
||||
@@ -84,7 +84,9 @@ describe("UserProjectionPage", () => {
|
||||
|
||||
await screen.findByText("Ory SSOT 시스템");
|
||||
expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /초기화 후 재구축/ })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /초기화 후 재구축/ }),
|
||||
).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -209,6 +209,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
>
|
||||
<Input
|
||||
value={claim.key}
|
||||
name={`global-claim-definition-key-${claim.id}`}
|
||||
className="font-mono text-xs"
|
||||
placeholder="claim_key"
|
||||
data-testid={`global-claim-definition-key-${claim.key || claim.id}`}
|
||||
@@ -218,6 +219,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
/>
|
||||
<Input
|
||||
value={claim.label}
|
||||
name={`global-claim-definition-label-${claim.id}`}
|
||||
placeholder={t(
|
||||
"ui.admin.users.global_custom_claims.label_placeholder",
|
||||
"표시 이름",
|
||||
@@ -233,6 +235,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
"Claim 타입",
|
||||
)}
|
||||
value={claim.valueType}
|
||||
name={`global-claim-definition-value-type-${claim.id}`}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
onChange={(event) =>
|
||||
updateClaim(claim.id, {
|
||||
@@ -253,6 +256,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
"읽기 권한",
|
||||
)}
|
||||
value={claim.readPermission}
|
||||
name={`global-claim-definition-read-permission-${claim.id}`}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
data-testid={`global-claim-definition-read-permission-${claim.key || claim.id}`}
|
||||
onChange={(event) =>
|
||||
@@ -274,6 +278,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
"쓰기 권한",
|
||||
)}
|
||||
value={claim.writePermission}
|
||||
name={`global-claim-definition-write-permission-${claim.id}`}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
data-testid={`global-claim-definition-write-permission-${claim.key || claim.id}`}
|
||||
onChange={(event) =>
|
||||
@@ -291,6 +296,7 @@ export default function GlobalCustomClaimsPage() {
|
||||
</select>
|
||||
<Input
|
||||
value={claim.description || ""}
|
||||
name={`global-claim-definition-description-${claim.id}`}
|
||||
placeholder={t(
|
||||
"ui.admin.users.global_custom_claims.description_placeholder",
|
||||
"설명",
|
||||
|
||||
@@ -186,7 +186,9 @@ describe("UserDetailPage Worksmobile employee number", () => {
|
||||
expect(valueInput).toHaveAttribute("type", "date");
|
||||
|
||||
fireEvent.change(valueInput, { target: { value: "2026-07-01" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /사용자 Claim 값 저장/ }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /사용자 Claim 값 저장/ }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -86,9 +86,7 @@ describe("tenantTree utility", () => {
|
||||
|
||||
expect(currentBase?.recursiveMemberCount).toBe(17);
|
||||
expect(currentBase?.children[0]?.recursiveMemberCount).toBe(7);
|
||||
expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(
|
||||
2,
|
||||
);
|
||||
expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps total member counts when descendants are not loaded on the current page", () => {
|
||||
|
||||
@@ -26,8 +26,11 @@ describe("adminfront form field diagnostics", () => {
|
||||
|
||||
for (const file of sourceFiles("src")) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = formFieldTagPattern.exec(source))) {
|
||||
for (
|
||||
let match = formFieldTagPattern.exec(source);
|
||||
match !== null;
|
||||
match = formFieldTagPattern.exec(source)
|
||||
) {
|
||||
const tag = match[0];
|
||||
if (/\b(id|name)\s*=/.test(tag)) continue;
|
||||
if (/\{\.\.\s*[^}]+\}/.test(tag)) continue;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
export function anonymousFormFields(container: ParentNode) {
|
||||
return Array.from(container.querySelectorAll("input, select, textarea")).filter(
|
||||
return Array.from(
|
||||
container.querySelectorAll("input, select, textarea"),
|
||||
).filter(
|
||||
(field) =>
|
||||
!field.getAttribute("id")?.trim() &&
|
||||
!field.getAttribute("name")?.trim(),
|
||||
!field.getAttribute("id")?.trim() && !field.getAttribute("name")?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,12 +67,14 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"ui.admin.ory_ssot.title": "Ory SSOT 시스템",
|
||||
"msg.admin.ory_ssot.flush_confirm":
|
||||
"Redis identity cache 키만 비우시겠습니까?",
|
||||
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush에 실패했습니다.",
|
||||
"msg.admin.ory_ssot.flush_error":
|
||||
"Redis identity cache flush에 실패했습니다.",
|
||||
"msg.admin.ory_ssot.flush_success":
|
||||
"Redis identity cache key {{count}}개를 비웠습니다.",
|
||||
"msg.admin.ory_ssot.forbidden.description":
|
||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||
"msg.admin.ory_ssot.load_error": "Ory SSOT 시스템 상태를 불러오지 못했습니다.",
|
||||
"msg.admin.ory_ssot.load_error":
|
||||
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
|
||||
"msg.admin.ory_ssot.subtitle":
|
||||
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
||||
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
|
||||
@@ -156,8 +158,7 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
||||
"ui.admin.ory_ssot.summary.status": "Status",
|
||||
"ui.admin.ory_ssot.summary.updated_at": "Updated at",
|
||||
"ui.admin.ory_ssot.title": "Ory SSOT System",
|
||||
"msg.admin.ory_ssot.flush_confirm":
|
||||
"Flush only Redis identity cache keys?",
|
||||
"msg.admin.ory_ssot.flush_confirm": "Flush only Redis identity cache keys?",
|
||||
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush failed.",
|
||||
"msg.admin.ory_ssot.flush_success":
|
||||
"Flushed {{count}} Redis identity cache keys.",
|
||||
|
||||
@@ -179,9 +179,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
await expect(page.locator('a[href="/tenants"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="/api-keys"]')).toBeVisible();
|
||||
await expect(page.locator('a[href="/audit-logs"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/system/ory-ssot"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('a[href="/system/ory-ssot"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('a[href="/system/data-integrity"]'),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -202,6 +202,7 @@ test.describe("Tenants Management", () => {
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": 'attachment; filename="tenant-users.csv"',
|
||||
"access-control-expose-headers": "content-disposition",
|
||||
},
|
||||
body: "email,name\nmember@example.com,Member User\n",
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
"../common/**/node_modules/**",
|
||||
"../common/.pnpm-store/**",
|
||||
`${commonRoot}/theme/**`,
|
||||
`${commonRoot}/core/components/audit/AuditLogTable.tsx`,
|
||||
`${commonRoot}/core/pagination/*.worker.ts`,
|
||||
`${commonRoot}/core/query/queryClient.ts`,
|
||||
],
|
||||
|
||||
@@ -100,7 +100,6 @@ func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T)
|
||||
output := &strings.Builder{}
|
||||
|
||||
count, err := auditWorksmobileDuplicatePhoneCountryCodes(context.Background(), output, true, client)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("auditWorksmobileDuplicatePhoneCountryCodes returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ type DevHandler struct {
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
RPSvc service.RelyingPartyService
|
||||
TenantSvc service.TenantService
|
||||
DeveloperSvc *service.DeveloperService
|
||||
DeveloperSvc developerRequestService
|
||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||
RPUsageQueries domain.RPUsageQueryRepository
|
||||
Auth interface {
|
||||
@@ -47,6 +47,16 @@ type DevHandler struct {
|
||||
}
|
||||
}
|
||||
|
||||
type developerRequestService interface {
|
||||
RequestAccess(ctx context.Context, req domain.DeveloperRequest) error
|
||||
GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error)
|
||||
GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error)
|
||||
ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error)
|
||||
ApproveRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
RejectRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error
|
||||
}
|
||||
|
||||
func NewDevHandler(
|
||||
redis domain.RedisRepository,
|
||||
secretRepo domain.ClientSecretRepository,
|
||||
@@ -426,7 +436,28 @@ func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domai
|
||||
return false
|
||||
}
|
||||
allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", tenantID, "grant_dev_permissions")
|
||||
return err == nil && allowed
|
||||
if err == nil && allowed {
|
||||
return true
|
||||
}
|
||||
return h.hasApprovedDeveloperRequest(c, profile, tenantID)
|
||||
}
|
||||
|
||||
func (h *DevHandler) hasApprovedDeveloperRequest(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool {
|
||||
if h.DeveloperSvc == nil || profile == nil {
|
||||
return false
|
||||
}
|
||||
userID := strings.TrimSpace(profile.ID)
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if userID == "" || tenantID == "" {
|
||||
return false
|
||||
}
|
||||
status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID)
|
||||
if err != nil || status == nil {
|
||||
return false
|
||||
}
|
||||
return status.Status == domain.DeveloperRequestStatusApproved &&
|
||||
strings.TrimSpace(status.UserID) == userID &&
|
||||
strings.TrimSpace(status.TenantID) == tenantID
|
||||
}
|
||||
|
||||
func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool {
|
||||
|
||||
@@ -62,6 +62,54 @@ func (m *devMockKetoService) ListObjects(ctx context.Context, ns, rel, sub strin
|
||||
return args.Get(0).([]string), args.Error(1)
|
||||
}
|
||||
|
||||
type devMockDeveloperService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) RequestAccess(ctx context.Context, req domain.DeveloperRequest) error {
|
||||
args := m.Called(ctx, req)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperRequest, error) {
|
||||
args := m.Called(ctx, userID, tenantID)
|
||||
if req, ok := args.Get(0).(*domain.DeveloperRequest); ok {
|
||||
return req, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if req, ok := args.Get(0).(*domain.DeveloperRequest); ok {
|
||||
return req, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) ListRequests(ctx context.Context, userID, status string) ([]domain.DeveloperRequest, error) {
|
||||
args := m.Called(ctx, userID, status)
|
||||
if requests, ok := args.Get(0).([]domain.DeveloperRequest); ok {
|
||||
return requests, args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) ApproveRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
args := m.Called(ctx, id, adminNotes)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) RejectRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
args := m.Called(ctx, id, adminNotes)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *devMockDeveloperService) CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error {
|
||||
args := m.Called(ctx, id, adminNotes)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type devMockRedisRepo struct {
|
||||
data map[string]string
|
||||
}
|
||||
@@ -1521,6 +1569,66 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestCreateClient_ApprovedDeveloperRequestAllowsCreateWhenTenantGrantNotVisible(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/clients" {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
body["client_secret"] = "generated-secret"
|
||||
return httpJSONAny(r, http.StatusCreated, body), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(false, nil).Maybe()
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
|
||||
developerSvc := new(devMockDeveloperService)
|
||||
developerSvc.On("GetRequestStatus", mock.Anything, "user-1", "tenant-a").Return(&domain.DeveloperRequest{
|
||||
UserID: "user-1",
|
||||
TenantID: "tenant-a",
|
||||
Status: domain.DeveloperRequestStatusApproved,
|
||||
}, nil).Maybe()
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
SecretRepo: &mockSecretRepo{secrets: make(map[string]string)},
|
||||
Redis: &devMockRedisRepo{data: make(map[string]string)},
|
||||
Keto: mockKeto,
|
||||
DeveloperSvc: developerSvc,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
tenantID := "tenant-a"
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &tenantID,
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"id": "client-1",
|
||||
"name": "App One",
|
||||
"type": "private",
|
||||
"redirectUris": []string{"http://localhost/cb"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
mockKeto.AssertExpectations(t)
|
||||
developerSvc.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) {
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -315,26 +317,14 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
findRoot := func(id string) string {
|
||||
curr := id
|
||||
for {
|
||||
p, exists := parentMap[curr]
|
||||
if !exists || p == "" {
|
||||
break
|
||||
}
|
||||
curr = p
|
||||
}
|
||||
return curr
|
||||
}
|
||||
|
||||
roots := make(map[string]bool)
|
||||
for _, id := range baseTenantIDs {
|
||||
roots[findRoot(id)] = true
|
||||
roots[findTenantRootID(parentMap, id)] = true
|
||||
}
|
||||
|
||||
// Filter tenants that belong to the same tree family
|
||||
for _, t := range allTenants {
|
||||
if roots[findRoot(t.ID)] {
|
||||
if roots[findTenantRootID(parentMap, t.ID)] {
|
||||
tenants = append(tenants, t)
|
||||
}
|
||||
}
|
||||
@@ -2774,6 +2764,14 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
||||
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
||||
ttl := orgChartSnapshotCacheTTL()
|
||||
role, userID, profileTenantID := orgChartProfileLogValues(profile)
|
||||
slog.Info("orgchart snapshot request started",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
"profile_tenant_id", profileTenantID,
|
||||
"tenant_header", c.Get("X-Tenant-ID"),
|
||||
"cache_mode", cacheMode,
|
||||
)
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
|
||||
@@ -2785,13 +2783,43 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
TTLSeconds: int(ttl.Seconds()),
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "HIT")
|
||||
slog.Info("orgchart snapshot cache hit",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
"profile_tenant_id", profileTenantID,
|
||||
"tenant_header", c.Get("X-Tenant-ID"),
|
||||
"tenant_count", len(cached.Tenants),
|
||||
"user_count", len(cached.Users),
|
||||
)
|
||||
return c.JSON(cached)
|
||||
}
|
||||
slog.Warn("orgchart snapshot cache payload ignored",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
"profile_tenant_id", profileTenantID,
|
||||
"tenant_header", c.Get("X-Tenant-ID"),
|
||||
"error", err,
|
||||
)
|
||||
} else if err != nil && err != redis.Nil {
|
||||
slog.Warn("orgchart snapshot cache read failed",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
"profile_tenant_id", profileTenantID,
|
||||
"tenant_header", c.Get("X-Tenant-ID"),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
snapshot, err := h.buildOrgChartSnapshot(c.Context(), profile)
|
||||
if err != nil {
|
||||
slog.Error("orgchart snapshot build failed",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
"profile_tenant_id", profileTenantID,
|
||||
"tenant_header", c.Get("X-Tenant-ID"),
|
||||
"error", err,
|
||||
)
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||
}
|
||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||
@@ -2802,13 +2830,31 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
||||
|
||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||
if raw, err := json.Marshal(snapshot); err == nil {
|
||||
_ = h.OrgChartCache.Set(cacheKey, string(raw), ttl)
|
||||
if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil {
|
||||
slog.Warn("orgchart snapshot cache write failed",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
"profile_tenant_id", profileTenantID,
|
||||
"tenant_header", c.Get("X-Tenant-ID"),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
c.Set("X-Orgfront-Cache", "MISS")
|
||||
} else {
|
||||
c.Set("X-Orgfront-Cache", "BYPASS")
|
||||
}
|
||||
|
||||
slog.Info("orgchart snapshot request completed",
|
||||
"user_id", userID,
|
||||
"role", role,
|
||||
"profile_tenant_id", profileTenantID,
|
||||
"tenant_header", c.Get("X-Tenant-ID"),
|
||||
"cache_mode", cacheMode,
|
||||
"cache_result", c.GetRespHeader("X-Orgfront-Cache"),
|
||||
"tenant_count", len(snapshot.Tenants),
|
||||
"user_count", len(snapshot.Users),
|
||||
)
|
||||
return c.JSON(snapshot)
|
||||
}
|
||||
|
||||
@@ -2880,27 +2926,16 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi
|
||||
parentMap[tenant.ID] = *tenant.ParentID
|
||||
}
|
||||
}
|
||||
findRoot := func(id string) string {
|
||||
curr := id
|
||||
for {
|
||||
parentID, exists := parentMap[curr]
|
||||
if !exists || parentID == "" {
|
||||
return curr
|
||||
}
|
||||
curr = parentID
|
||||
}
|
||||
}
|
||||
|
||||
roots := make(map[string]bool)
|
||||
for _, id := range baseTenantIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
roots[findRoot(id)] = true
|
||||
roots[findTenantRootID(parentMap, id)] = true
|
||||
}
|
||||
}
|
||||
|
||||
tenants := make([]domain.Tenant, 0, len(allTenants))
|
||||
for _, tenant := range allTenants {
|
||||
if roots[findRoot(tenant.ID)] {
|
||||
if roots[findTenantRootID(parentMap, tenant.ID)] {
|
||||
tenants = append(tenants, tenant)
|
||||
}
|
||||
}
|
||||
@@ -2980,6 +3015,36 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
|
||||
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
|
||||
}
|
||||
|
||||
func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) {
|
||||
if profile == nil {
|
||||
return "anonymous", "anonymous", ""
|
||||
}
|
||||
tenantID := ""
|
||||
if profile.TenantID != nil {
|
||||
tenantID = strings.TrimSpace(*profile.TenantID)
|
||||
}
|
||||
return domain.NormalizeRole(profile.Role), strings.TrimSpace(profile.ID), tenantID
|
||||
}
|
||||
|
||||
func findTenantRootID(parentMap map[string]string, tenantID string) string {
|
||||
curr := strings.TrimSpace(tenantID)
|
||||
if curr == "" {
|
||||
return ""
|
||||
}
|
||||
visited := map[string]struct{}{}
|
||||
for {
|
||||
parentID := strings.TrimSpace(parentMap[curr])
|
||||
if parentID == "" || parentID == curr {
|
||||
return curr
|
||||
}
|
||||
if _, exists := visited[parentID]; exists {
|
||||
return parentID
|
||||
}
|
||||
visited[curr] = struct{}{}
|
||||
curr = parentID
|
||||
}
|
||||
}
|
||||
|
||||
func orgChartSnapshotCacheTTL() time.Duration {
|
||||
const defaultTTL = 5 * time.Minute
|
||||
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
|
||||
@@ -2996,16 +3061,26 @@ func orgChartSnapshotCacheTTL() time.Duration {
|
||||
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
slog.Warn("public orgchart rejected missing token")
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "share token is required")
|
||||
}
|
||||
|
||||
link, err := h.SharedLink.ValidateToken(c.Context(), token)
|
||||
if err != nil {
|
||||
slog.Warn("public orgchart token validation failed",
|
||||
"token_length", len(token),
|
||||
"error", err,
|
||||
)
|
||||
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
|
||||
}
|
||||
|
||||
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
|
||||
if err != nil {
|
||||
slog.Error("public orgchart tenant list failed",
|
||||
"link_id", link.ID,
|
||||
"tenant_id", link.TenantID,
|
||||
"error", err,
|
||||
)
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
@@ -3016,24 +3091,12 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
findRoot := func(id string) string {
|
||||
curr := id
|
||||
for {
|
||||
p, exists := parentMap[curr]
|
||||
if !exists || p == "" {
|
||||
break
|
||||
}
|
||||
curr = p
|
||||
}
|
||||
return curr
|
||||
}
|
||||
|
||||
sharedRootID := findRoot(link.TenantID)
|
||||
sharedRootID := findTenantRootID(parentMap, link.TenantID)
|
||||
var filteredTenants []domain.Tenant
|
||||
var tenantIDs []string
|
||||
|
||||
for _, t := range allTenants {
|
||||
if findRoot(t.ID) == sharedRootID {
|
||||
if findTenantRootID(parentMap, t.ID) == sharedRootID {
|
||||
filteredTenants = append(filteredTenants, t)
|
||||
}
|
||||
}
|
||||
@@ -3076,6 +3139,13 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
tenantSummaries = append(tenantSummaries, mapTenantSummary(t))
|
||||
}
|
||||
|
||||
slog.Info("public orgchart request completed",
|
||||
"link_id", link.ID,
|
||||
"tenant_id", link.TenantID,
|
||||
"shared_root_id", sharedRootID,
|
||||
"tenant_count", len(tenantSummaries),
|
||||
"user_count", len(publicUsers),
|
||||
)
|
||||
return c.JSON(fiber.Map{
|
||||
"tenants": tenantSummaries,
|
||||
"users": publicUsers,
|
||||
|
||||
@@ -405,6 +405,68 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
mockUsers := new(MockUserRepoForHandler)
|
||||
now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
|
||||
parent := func(id string) *string { return &id }
|
||||
familyID := "hanmac-family-id"
|
||||
samanID := "saman-id"
|
||||
teamID := "saman-platform-id"
|
||||
tenants := []domain.Tenant{
|
||||
{ID: familyID, Type: domain.TenantTypeCompanyGroup, ParentID: parent(familyID), Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: samanID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "삼안", Slug: "saman", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: teamID, Type: domain.TenantTypeUserGroup, ParentID: parent(samanID), Name: "플랫폼팀", Slug: "saman-platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
users := []domain.User{
|
||||
{ID: "user-1", Email: "user@samaneng.com", Name: "Saman User", Role: domain.RoleUser, Status: domain.UserStatusActive, TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
|
||||
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection}
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
ID: "user-1",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &samanID,
|
||||
JoinedTenants: []domain.Tenant{
|
||||
tenants[1],
|
||||
},
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
|
||||
|
||||
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
|
||||
})).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
|
||||
})).Return(map[string]int64{familyID: 1, samanID: 1, teamID: 0}, nil).Once()
|
||||
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
|
||||
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
|
||||
resp, err := app.Test(req, 1000)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var body struct {
|
||||
Tenants []tenantSummary `json:"tenants"`
|
||||
Users []userSummary `json:"users"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Len(t, body.Tenants, 3)
|
||||
require.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform"))
|
||||
require.Len(t, body.Users, 1)
|
||||
mockSvc.AssertExpectations(t)
|
||||
mockProjection.AssertExpectations(t)
|
||||
mockUsers.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
@@ -740,6 +802,25 @@ func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func tenantSummarySlugsMatch(got []tenantSummary, want ...string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
counts := make(map[string]int, len(want))
|
||||
for _, slug := range want {
|
||||
counts[slug]++
|
||||
}
|
||||
for _, tenant := range got {
|
||||
counts[tenant.Slug]--
|
||||
}
|
||||
for _, count := range counts {
|
||||
if count != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
|
||||
@@ -197,3 +197,10 @@ pending = "Pending"
|
||||
success = "Success"
|
||||
unchanged = "Unchanged"
|
||||
updated = "Updated"
|
||||
|
||||
[ui.common]
|
||||
searching = "Searching..."
|
||||
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = "Admin only"
|
||||
user_and_admin = "User and admin"
|
||||
|
||||
@@ -197,3 +197,10 @@ pending = "준비 중"
|
||||
success = "성공"
|
||||
unchanged = "동일"
|
||||
updated = "수정"
|
||||
|
||||
[ui.common]
|
||||
searching = "검색 중..."
|
||||
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = "관리자만 가능"
|
||||
user_and_admin = "사용자와 관리자"
|
||||
|
||||
@@ -197,3 +197,10 @@ pending = ""
|
||||
success = ""
|
||||
unchanged = ""
|
||||
updated = ""
|
||||
|
||||
[ui.common]
|
||||
searching = ""
|
||||
|
||||
[ui.common.custom_claim_permission]
|
||||
admin_only = ""
|
||||
user_and_admin = ""
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 802 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 810 KiB |
@@ -35,7 +35,8 @@ export function ClientDetailTabs({
|
||||
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||
{tabOrder.map((tab) => {
|
||||
const isActive = tab.key === activeTab;
|
||||
const labelKey = tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`;
|
||||
const labelKey =
|
||||
tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`;
|
||||
return isActive ? (
|
||||
<span
|
||||
key={tab.key}
|
||||
|
||||
@@ -38,7 +38,7 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
|
||||
|
||||
await expect(tabs).toHaveText([
|
||||
"연동 설정",
|
||||
"동의 및 사용자",
|
||||
"사용자 Claim",
|
||||
"설정",
|
||||
"관계",
|
||||
]);
|
||||
|
||||
@@ -35,10 +35,22 @@ test.describe("DevFront client tenant access settings", () => {
|
||||
});
|
||||
|
||||
test("adds and removes allowed tenants with UUID copy evidence", async ({
|
||||
context,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (value: string) => {
|
||||
window.localStorage.setItem("__e2e_copied_text", value);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const state = {
|
||||
clients: [
|
||||
@@ -82,7 +94,9 @@ test.describe("DevFront client tenant access settings", () => {
|
||||
).toContainText(existingTenantId);
|
||||
await page.getByTestId(`allowed-tenant-copy-${existingTenantId}`).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.poll(() =>
|
||||
page.evaluate(() => window.localStorage.getItem("__e2e_copied_text")),
|
||||
)
|
||||
.toBe(existingTenantId);
|
||||
|
||||
await page
|
||||
|
||||
@@ -2961,3 +2961,79 @@ tenant_slug = "Tenant slug"
|
||||
"ui.admin.tenants.data_mgmt" = "temp"
|
||||
"ui.admin.tenants.toggle_status" = "temp"
|
||||
"ui.admin.users.data_mgmt" = "temp"
|
||||
|
||||
[msg.admin.ory_ssot]
|
||||
flush_confirm = "Flush only Redis identity cache keys?"
|
||||
flush_error = "Redis identity cache flush failed."
|
||||
flush_success = "Flushed {{count}} Redis identity cache keys."
|
||||
load_error = "Failed to load Ory SSOT system status."
|
||||
subtitle = "Review Kratos source-of-truth and Redis identity cache status separately."
|
||||
|
||||
[msg.admin.ory_ssot.forbidden]
|
||||
description = "This screen is only available to super_admin users."
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
add_error = "Failed to add members"
|
||||
add_success = "Added {{count}} members."
|
||||
|
||||
[msg.admin.users.global_custom_claims]
|
||||
description = "Manage user claim definitions shared by all RPs and default read/write permissions."
|
||||
empty = "No global claims are defined."
|
||||
registry = "Only defined claim keys are available for global claim value management on user details."
|
||||
|
||||
[ui.admin.integrity]
|
||||
tab_ory_ssot = "Ory SSOT System"
|
||||
|
||||
[ui.admin.ory_ssot]
|
||||
loading = "Loading Ory SSOT status..."
|
||||
title = "Ory SSOT System"
|
||||
|
||||
[ui.admin.ory_ssot.actions]
|
||||
flush_identity_cache = "Redis cache flush"
|
||||
|
||||
[ui.admin.ory_ssot.cache_card]
|
||||
description = "Redis mirror/cache status for Kratos identity list and lookup operations."
|
||||
title = "Redis identity cache"
|
||||
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = "Access denied"
|
||||
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = "PostgreSQL read model status used by admin search and statistics."
|
||||
title = "Backend user read model"
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = "failed"
|
||||
not_ready = "not ready"
|
||||
ready = "ready"
|
||||
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = "Cache keys"
|
||||
last_refreshed = "Last refreshed"
|
||||
last_synced = "Last read-model refresh"
|
||||
local_users = "Local users"
|
||||
observed_identities = "Observed identities"
|
||||
status = "Status"
|
||||
updated_at = "Updated at"
|
||||
|
||||
[ui.admin.tenants]
|
||||
search_match_badge = "Search match"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
add_existing_description = "Select search results into an add queue, then assign them in one operation."
|
||||
add_queued = "Add selected members"
|
||||
export = "Selected organization users CSV"
|
||||
queue_empty = "Select members to add."
|
||||
queue_remove = "Remove from add queue"
|
||||
search_min_length = "Enter at least two characters."
|
||||
search_placeholder = "Search by name or email"
|
||||
|
||||
[ui.admin.users.global_custom_claims]
|
||||
description_placeholder = "Optional claim description"
|
||||
label_placeholder = "Display name"
|
||||
manage_definitions = "Manage Global Definitions"
|
||||
read_permission = "Read permission"
|
||||
registry = "Global Claim Registry"
|
||||
title = "Global Claim Settings"
|
||||
value_type = "Claim type"
|
||||
write_permission = "Write permission"
|
||||
|
||||
@@ -3387,3 +3387,79 @@ tenant_slug = "테넌트 slug"
|
||||
"ui.admin.tenants.data_mgmt" = "temp"
|
||||
"ui.admin.tenants.toggle_status" = "temp"
|
||||
"ui.admin.users.data_mgmt" = "temp"
|
||||
|
||||
[msg.admin.ory_ssot]
|
||||
flush_confirm = "Redis identity cache 키만 비우시겠습니까?"
|
||||
flush_error = "Redis identity cache flush에 실패했습니다."
|
||||
flush_success = "Redis identity cache key {{count}}개를 비웠습니다."
|
||||
load_error = "Ory SSOT 시스템 상태를 불러오지 못했습니다."
|
||||
subtitle = "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다."
|
||||
|
||||
[msg.admin.ory_ssot.forbidden]
|
||||
description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
add_error = "구성원 추가 실패"
|
||||
add_success = "{{count}}명의 구성원이 추가되었습니다."
|
||||
|
||||
[msg.admin.users.global_custom_claims]
|
||||
description = "모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다."
|
||||
empty = "정의된 전역 claim이 없습니다."
|
||||
registry = "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다."
|
||||
|
||||
[ui.admin.integrity]
|
||||
tab_ory_ssot = "Ory SSOT 시스템"
|
||||
|
||||
[ui.admin.ory_ssot]
|
||||
loading = "불러오는 중"
|
||||
title = "Ory SSOT 시스템"
|
||||
|
||||
[ui.admin.ory_ssot.actions]
|
||||
flush_identity_cache = "Redis cache flush"
|
||||
|
||||
[ui.admin.ory_ssot.cache_card]
|
||||
description = "Kratos identity 목록 및 조회 작업을 위한 Redis mirror/cache 상태입니다."
|
||||
title = "Redis identity cache"
|
||||
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = "접근 권한이 없습니다"
|
||||
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다."
|
||||
title = "Backend 사용자 read model"
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = "실패"
|
||||
not_ready = "준비되지 않음"
|
||||
ready = "준비됨"
|
||||
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = "Cache keys"
|
||||
last_refreshed = "마지막 refresh"
|
||||
last_synced = "마지막 read-model refresh"
|
||||
local_users = "Local users"
|
||||
observed_identities = "관측 identity"
|
||||
status = "상태"
|
||||
updated_at = "상태 갱신"
|
||||
|
||||
[ui.admin.tenants]
|
||||
search_match_badge = "검색 일치"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
add_existing_description = "검색 결과를 선택해 추가 명단에 담은 뒤 한 번에 배정합니다."
|
||||
add_queued = "선택 구성원 추가"
|
||||
export = "선택 조직 사용자 CSV"
|
||||
queue_empty = "추가할 구성원을 선택하세요."
|
||||
queue_remove = "추가 명단에서 제거"
|
||||
search_min_length = "두 글자 이상 입력하세요."
|
||||
search_placeholder = "이름 또는 이메일 검색"
|
||||
|
||||
[ui.admin.users.global_custom_claims]
|
||||
description_placeholder = "설명"
|
||||
label_placeholder = "표시 이름"
|
||||
manage_definitions = "전역 정의 관리"
|
||||
read_permission = "읽기 권한"
|
||||
registry = "Global Claim Registry"
|
||||
title = "전역 Claim 설정"
|
||||
value_type = "Claim 타입"
|
||||
write_permission = "쓰기 권한"
|
||||
|
||||
@@ -3284,3 +3284,79 @@ tenant_slug = ""
|
||||
"ui.admin.tenants.data_mgmt" = "temp"
|
||||
"ui.admin.tenants.toggle_status" = "temp"
|
||||
"ui.admin.users.data_mgmt" = "temp"
|
||||
|
||||
[msg.admin.ory_ssot]
|
||||
flush_confirm = ""
|
||||
flush_error = ""
|
||||
flush_success = ""
|
||||
load_error = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.ory_ssot.forbidden]
|
||||
description = ""
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
add_error = ""
|
||||
add_success = ""
|
||||
|
||||
[msg.admin.users.global_custom_claims]
|
||||
description = ""
|
||||
empty = ""
|
||||
registry = ""
|
||||
|
||||
[ui.admin.integrity]
|
||||
tab_ory_ssot = ""
|
||||
|
||||
[ui.admin.ory_ssot]
|
||||
loading = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.actions]
|
||||
flush_identity_cache = ""
|
||||
|
||||
[ui.admin.ory_ssot.cache_card]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.forbidden]
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.projection_card]
|
||||
description = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.ory_ssot.status]
|
||||
failed = ""
|
||||
not_ready = ""
|
||||
ready = ""
|
||||
|
||||
[ui.admin.ory_ssot.summary]
|
||||
cache_keys = ""
|
||||
last_refreshed = ""
|
||||
last_synced = ""
|
||||
local_users = ""
|
||||
observed_identities = ""
|
||||
status = ""
|
||||
updated_at = ""
|
||||
|
||||
[ui.admin.tenants]
|
||||
search_match_badge = ""
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
add_existing_description = ""
|
||||
add_queued = ""
|
||||
export = ""
|
||||
queue_empty = ""
|
||||
queue_remove = ""
|
||||
search_min_length = ""
|
||||
search_placeholder = ""
|
||||
|
||||
[ui.admin.users.global_custom_claims]
|
||||
description_placeholder = ""
|
||||
label_placeholder = ""
|
||||
manage_definitions = ""
|
||||
read_permission = ""
|
||||
registry = ""
|
||||
title = ""
|
||||
value_type = ""
|
||||
write_permission = ""
|
||||
|
||||
@@ -40,6 +40,15 @@ type ViewBox = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
type OrgChartLoadErrorDiagnostics = {
|
||||
cacheMode: "redis" | "public";
|
||||
code: string;
|
||||
message: string;
|
||||
route: string;
|
||||
status: number | null;
|
||||
tenantId: string;
|
||||
};
|
||||
|
||||
type OrgSelectionDescendantOption = {
|
||||
depth: 1 | 2;
|
||||
id: string;
|
||||
@@ -1215,6 +1224,36 @@ function normalizeOrgSlug(value: unknown) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function readErrorText(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function getOrgChartLoadErrorDiagnostics(
|
||||
error: unknown,
|
||||
options: { cacheMode: "redis" | "public"; route: string; tenantId: string },
|
||||
): OrgChartLoadErrorDiagnostics {
|
||||
const maybeError = error as {
|
||||
config?: { url?: string };
|
||||
message?: string;
|
||||
response?: {
|
||||
data?: { code?: unknown; error?: unknown; message?: unknown };
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
const responseData = maybeError.response?.data;
|
||||
return {
|
||||
cacheMode: options.cacheMode,
|
||||
code: readErrorText(responseData?.code),
|
||||
message:
|
||||
readErrorText(responseData?.error) ||
|
||||
readErrorText(responseData?.message) ||
|
||||
readErrorText(maybeError.message),
|
||||
route: maybeError.config?.url || options.route,
|
||||
status: maybeError.response?.status ?? null,
|
||||
tenantId: options.tenantId,
|
||||
};
|
||||
}
|
||||
|
||||
function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] {
|
||||
const rawAppointments = user.metadata?.additionalAppointments;
|
||||
if (!Array.isArray(rawAppointments)) return [];
|
||||
@@ -1443,7 +1482,10 @@ export function TenantOrgChartPage() {
|
||||
}
|
||||
|
||||
const rootNodes = buildTenantFullTree(
|
||||
filterSystemGlobalTenants(orgChartSnapshotQuery.data.tenants, visibilityMode),
|
||||
filterSystemGlobalTenants(
|
||||
orgChartSnapshotQuery.data.tenants,
|
||||
visibilityMode,
|
||||
),
|
||||
).subTree;
|
||||
const membershipRootNodes = buildTenantFullTree(
|
||||
filterOrgChartMembershipTenants(orgChartSnapshotQuery.data.tenants),
|
||||
@@ -1601,6 +1643,24 @@ export function TenantOrgChartPage() {
|
||||
const isError = shareToken
|
||||
? publicQuery.isError
|
||||
: orgChartSnapshotQuery.isError;
|
||||
const currentLoadError = shareToken
|
||||
? publicQuery.error
|
||||
: orgChartSnapshotQuery.error;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentLoadError) return;
|
||||
console.error(
|
||||
"[orgfront] Org chart load failed",
|
||||
getOrgChartLoadErrorDiagnostics(currentLoadError, {
|
||||
cacheMode: shareToken ? "public" : "redis",
|
||||
route: shareToken
|
||||
? "/v1/public/orgchart"
|
||||
: "/v1/admin/orgchart/snapshot",
|
||||
tenantId:
|
||||
tenantId ?? window.localStorage.getItem("dev_tenant_id") ?? "",
|
||||
}),
|
||||
);
|
||||
}, [currentLoadError, shareToken, tenantId]);
|
||||
|
||||
const totalUsers = React.useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -1619,9 +1679,12 @@ export function TenantOrgChartPage() {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const errorMessage = shareToken
|
||||
? "조직도를 불러올 수 없거나 만료된 링크입니다."
|
||||
: "조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요.";
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
조직도를 불러올 수 없거나 만료된 링크입니다.
|
||||
{errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
46
orgfront/src/lib/tenantTree.test.ts
Normal file
46
orgfront/src/lib/tenantTree.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "./adminApi";
|
||||
import { buildTenantFullTree } from "./tenantTree";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
slug: string,
|
||||
parentId?: string,
|
||||
type = "USER_GROUP",
|
||||
): TenantSummary {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: slug,
|
||||
slug,
|
||||
description: "",
|
||||
status: "active",
|
||||
parentId,
|
||||
memberCount: 0,
|
||||
totalMemberCount: 0,
|
||||
createdAt: "2026-06-10T00:00:00.000Z",
|
||||
updatedAt: "2026-06-10T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTenantFullTree", () => {
|
||||
it("treats a self-parent hanmac-family tenant as a root", () => {
|
||||
const result = buildTenantFullTree([
|
||||
tenant(
|
||||
"hanmac-family-id",
|
||||
"hanmac-family",
|
||||
"hanmac-family-id",
|
||||
"COMPANY_GROUP",
|
||||
),
|
||||
tenant("saman-id", "saman", "hanmac-family-id", "COMPANY"),
|
||||
tenant("platform-id", "platform", "saman-id"),
|
||||
]);
|
||||
|
||||
expect(result.subTree).toHaveLength(1);
|
||||
expect(result.subTree[0]?.id).toBe("hanmac-family-id");
|
||||
expect(result.subTree[0]?.children[0]?.id).toBe("saman-id");
|
||||
expect(result.subTree[0]?.children[0]?.children[0]?.id).toBe(
|
||||
"platform-id",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -60,9 +60,9 @@ export function buildTenantFullTree(
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate for all top-level nodes (those without parent)
|
||||
// Calculate for all top-level nodes (those without parent or with self-parent)
|
||||
for (const node of tenantMap.values()) {
|
||||
if (!node.parentId) {
|
||||
if (!node.parentId || node.parentId === node.id) {
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(node);
|
||||
}
|
||||
@@ -81,6 +81,8 @@ export function buildTenantFullTree(
|
||||
}
|
||||
|
||||
// If no rootId, return all top-level roots as subTree
|
||||
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
|
||||
const roots = Array.from(tenantMap.values()).filter(
|
||||
(n) => !n.parentId || n.parentId === n.id,
|
||||
);
|
||||
return { currentBase: null, subTree: roots };
|
||||
}
|
||||
|
||||
@@ -308,7 +308,9 @@ test("org chart defaults to hanmac family when public sector group is listed fir
|
||||
await page.goto("/chart");
|
||||
|
||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||
await expect(page.getByRole("button", { name: "조직: 한맥가족" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "조직: 한맥가족" }),
|
||||
).toBeVisible();
|
||||
await expect(svg.getByText("한맥가족", { exact: true })).toBeVisible();
|
||||
await expect(svg.getByText("삼안", { exact: true })).toBeVisible();
|
||||
await expect(svg.getByText("공공기관", { exact: true })).toHaveCount(0);
|
||||
|
||||
@@ -515,10 +515,13 @@ test("org chart allows a user in a hanmac-family descendant tenant", async ({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
tenants: [
|
||||
{
|
||||
...tenant("hanmac-family-id", "한맥가족", "hanmac-family"),
|
||||
type: "COMPANY_GROUP",
|
||||
},
|
||||
tenant(
|
||||
"hanmac-family-id",
|
||||
"한맥가족",
|
||||
"hanmac-family",
|
||||
"hanmac-family-id",
|
||||
"COMPANY_GROUP",
|
||||
),
|
||||
tenant("saman-id", "삼안", "saman", "hanmac-family-id", "COMPANY"),
|
||||
tenant("saman-platform-id", "플랫폼팀", "saman-platform", "saman-id"),
|
||||
],
|
||||
@@ -562,6 +565,57 @@ test("org chart allows a user in a hanmac-family descendant tenant", async ({
|
||||
await expect(svg.getByText(/Saman Descendant User/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("org chart logs authenticated snapshot failures with actionable diagnostics", async ({
|
||||
page,
|
||||
}) => {
|
||||
const consoleMessages: string[] = [];
|
||||
page.on("console", async (message) => {
|
||||
if (message.type() !== "error") return;
|
||||
const values = await Promise.all(
|
||||
message.args().map((arg) => arg.jsonValue().catch(() => "")),
|
||||
);
|
||||
consoleMessages.push(JSON.stringify(values));
|
||||
});
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("playwright_auth_bypass", "1");
|
||||
window.localStorage.setItem("dev_tenant_id", "saman-id");
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
status: 503,
|
||||
body: JSON.stringify({
|
||||
code: "service_unavailable",
|
||||
error: "tenant root traversal failed",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/chart");
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("조직도를 불러올 수 없거나 만료된 링크입니다."),
|
||||
).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
consoleMessages.some(
|
||||
(message) =>
|
||||
message.includes("[orgfront] Org chart load failed") &&
|
||||
message.includes("service_unavailable") &&
|
||||
message.includes("saman-id") &&
|
||||
message.includes("/v1/admin/orgchart/snapshot"),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("org chart places GPDTDC representative users on visible leaf appointments", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user