forked from baron/baron-sso
orgfront 버그 픽스
This commit is contained in:
@@ -143,6 +143,7 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
login_count: 3,
|
login_count: 3,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })),
|
||||||
fetchPasswordPolicy: vi.fn(async () => ({
|
fetchPasswordPolicy: vi.fn(async () => ({
|
||||||
minLength: 12,
|
minLength: 12,
|
||||||
lowercase: true,
|
lowercase: true,
|
||||||
@@ -196,6 +197,7 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
worksmobileId: "works-user-1",
|
worksmobileId: "works-user-1",
|
||||||
worksmobileName: "Engineer User",
|
worksmobileName: "Engineer User",
|
||||||
worksmobileEmail: "engineer@example.com",
|
worksmobileEmail: "engineer@example.com",
|
||||||
|
worksmobileDomainId: 1001,
|
||||||
worksmobilePrimaryOrgId: "works-org-1",
|
worksmobilePrimaryOrgId: "works-org-1",
|
||||||
worksmobilePrimaryOrgName: "기술연구팀",
|
worksmobilePrimaryOrgName: "기술연구팀",
|
||||||
status: "matched",
|
status: "matched",
|
||||||
@@ -380,17 +382,19 @@ describe("adminfront large page coverage smoke", () => {
|
|||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||||
);
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
|
||||||
|
target: { value: "InitialPassword!1" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
||||||
"tenant-company",
|
"tenant-company",
|
||||||
"user-2",
|
"user-2",
|
||||||
expect.any(String),
|
undefined,
|
||||||
|
"InitialPassword!1",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const credentialBatchId = vi.mocked(
|
|
||||||
adminApi.enqueueWorksmobileUserSync,
|
|
||||||
).mock.calls[0][2];
|
|
||||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -416,6 +420,10 @@ describe("adminfront large page coverage smoke", () => {
|
|||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||||
);
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
|
||||||
|
target: { value: "InitialPassword!1" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
||||||
@@ -424,21 +432,20 @@ describe("adminfront large page coverage smoke", () => {
|
|||||||
1,
|
1,
|
||||||
"tenant-company",
|
"tenant-company",
|
||||||
"user-2",
|
"user-2",
|
||||||
expect.any(String),
|
undefined,
|
||||||
|
"InitialPassword!1",
|
||||||
);
|
);
|
||||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
"tenant-company",
|
"tenant-company",
|
||||||
"user-3",
|
"user-3",
|
||||||
expect.any(String),
|
undefined,
|
||||||
|
"InitialPassword!1",
|
||||||
);
|
);
|
||||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downloads or deletes Worksmobile credential batches from history", async () => {
|
it("renders and retries Worksmobile jobs from history", async () => {
|
||||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
|
||||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
@@ -450,45 +457,20 @@ describe("adminfront large page coverage smoke", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
|
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
|
||||||
await screen.findByText("credential-batch-1");
|
expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0);
|
||||||
expect(
|
expect(screen.getByText("failed")).toBeInTheDocument();
|
||||||
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"),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(
|
fireEvent.click(screen.getAllByRole("button", { name: "" })[0]);
|
||||||
screen.getByRole("button", {
|
|
||||||
name: "credential-batch-1 비밀번호 값 삭제",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith(
|
||||||
adminApi.deleteWorksmobileCredentialBatchPasswords,
|
"tenant-company",
|
||||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
"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 () => {
|
it("opens Worksmobile password management for matched users", async () => {
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
@@ -504,17 +486,21 @@ describe("adminfront large page coverage smoke", () => {
|
|||||||
await screen.findAllByText("Engineer User");
|
await screen.findAllByText("Engineer User");
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
name: "Engineer User 비밀번호 재설정",
|
name: "Engineer User 비밀번호 관리",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith(
|
expect.stringContaining(
|
||||||
"tenant-company",
|
"https://auth.worksmobile.com/integrate/password/manage",
|
||||||
"user-1",
|
|
||||||
expect.any(String),
|
|
||||||
),
|
),
|
||||||
|
"_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 () => {
|
it("renders Ory SSOT cache management inside data integrity", async () => {
|
||||||
renderPage();
|
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(await screen.findByText("Redis identity cache")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("152")).toBeInTheDocument();
|
expect(screen.getByText("152")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ describe("UserProjectionPage", () => {
|
|||||||
|
|
||||||
await screen.findByText("Ory SSOT 시스템");
|
await screen.findByText("Ory SSOT 시스템");
|
||||||
expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull();
|
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/ }));
|
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export default function GlobalCustomClaimsPage() {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={claim.key}
|
value={claim.key}
|
||||||
|
name={`global-claim-definition-key-${claim.id}`}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
placeholder="claim_key"
|
placeholder="claim_key"
|
||||||
data-testid={`global-claim-definition-key-${claim.key || claim.id}`}
|
data-testid={`global-claim-definition-key-${claim.key || claim.id}`}
|
||||||
@@ -218,6 +219,7 @@ export default function GlobalCustomClaimsPage() {
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={claim.label}
|
value={claim.label}
|
||||||
|
name={`global-claim-definition-label-${claim.id}`}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.users.global_custom_claims.label_placeholder",
|
"ui.admin.users.global_custom_claims.label_placeholder",
|
||||||
"표시 이름",
|
"표시 이름",
|
||||||
@@ -233,6 +235,7 @@ export default function GlobalCustomClaimsPage() {
|
|||||||
"Claim 타입",
|
"Claim 타입",
|
||||||
)}
|
)}
|
||||||
value={claim.valueType}
|
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"
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateClaim(claim.id, {
|
updateClaim(claim.id, {
|
||||||
@@ -253,6 +256,7 @@ export default function GlobalCustomClaimsPage() {
|
|||||||
"읽기 권한",
|
"읽기 권한",
|
||||||
)}
|
)}
|
||||||
value={claim.readPermission}
|
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"
|
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}`}
|
data-testid={`global-claim-definition-read-permission-${claim.key || claim.id}`}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -274,6 +278,7 @@ export default function GlobalCustomClaimsPage() {
|
|||||||
"쓰기 권한",
|
"쓰기 권한",
|
||||||
)}
|
)}
|
||||||
value={claim.writePermission}
|
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"
|
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}`}
|
data-testid={`global-claim-definition-write-permission-${claim.key || claim.id}`}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -291,6 +296,7 @@ export default function GlobalCustomClaimsPage() {
|
|||||||
</select>
|
</select>
|
||||||
<Input
|
<Input
|
||||||
value={claim.description || ""}
|
value={claim.description || ""}
|
||||||
|
name={`global-claim-definition-description-${claim.id}`}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.users.global_custom_claims.description_placeholder",
|
"ui.admin.users.global_custom_claims.description_placeholder",
|
||||||
"설명",
|
"설명",
|
||||||
|
|||||||
@@ -186,7 +186,9 @@ describe("UserDetailPage Worksmobile employee number", () => {
|
|||||||
expect(valueInput).toHaveAttribute("type", "date");
|
expect(valueInput).toHaveAttribute("type", "date");
|
||||||
|
|
||||||
fireEvent.change(valueInput, { target: { value: "2026-07-01" } });
|
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());
|
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||||
expect(updateUserMock).toHaveBeenCalledWith(
|
expect(updateUserMock).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -86,9 +86,7 @@ describe("tenantTree utility", () => {
|
|||||||
|
|
||||||
expect(currentBase?.recursiveMemberCount).toBe(17);
|
expect(currentBase?.recursiveMemberCount).toBe(17);
|
||||||
expect(currentBase?.children[0]?.recursiveMemberCount).toBe(7);
|
expect(currentBase?.children[0]?.recursiveMemberCount).toBe(7);
|
||||||
expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(
|
expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(2);
|
||||||
2,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps total member counts when descendants are not loaded on the current page", () => {
|
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")) {
|
for (const file of sourceFiles("src")) {
|
||||||
const source = readFileSync(file, "utf8");
|
const source = readFileSync(file, "utf8");
|
||||||
let match: RegExpExecArray | null;
|
for (
|
||||||
while ((match = formFieldTagPattern.exec(source))) {
|
let match = formFieldTagPattern.exec(source);
|
||||||
|
match !== null;
|
||||||
|
match = formFieldTagPattern.exec(source)
|
||||||
|
) {
|
||||||
const tag = match[0];
|
const tag = match[0];
|
||||||
if (/\b(id|name)\s*=/.test(tag)) continue;
|
if (/\b(id|name)\s*=/.test(tag)) continue;
|
||||||
if (/\{\.\.\s*[^}]+\}/.test(tag)) continue;
|
if (/\{\.\.\s*[^}]+\}/.test(tag)) continue;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { expect } from "vitest";
|
import { expect } from "vitest";
|
||||||
|
|
||||||
export function anonymousFormFields(container: ParentNode) {
|
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) =>
|
||||||
!field.getAttribute("id")?.trim() &&
|
!field.getAttribute("id")?.trim() && !field.getAttribute("name")?.trim(),
|
||||||
!field.getAttribute("name")?.trim(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
|
|||||||
"ui.admin.ory_ssot.title": "Ory SSOT 시스템",
|
"ui.admin.ory_ssot.title": "Ory SSOT 시스템",
|
||||||
"msg.admin.ory_ssot.flush_confirm":
|
"msg.admin.ory_ssot.flush_confirm":
|
||||||
"Redis identity cache 키만 비우시겠습니까?",
|
"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":
|
"msg.admin.ory_ssot.flush_success":
|
||||||
"Redis identity cache key {{count}}개를 비웠습니다.",
|
"Redis identity cache key {{count}}개를 비웠습니다.",
|
||||||
"msg.admin.ory_ssot.forbidden.description":
|
"msg.admin.ory_ssot.forbidden.description":
|
||||||
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
||||||
"msg.admin.ory_ssot.load_error": "Ory SSOT 시스템 상태를 불러오지 못했습니다.",
|
"msg.admin.ory_ssot.load_error":
|
||||||
|
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
|
||||||
"msg.admin.ory_ssot.subtitle":
|
"msg.admin.ory_ssot.subtitle":
|
||||||
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
|
||||||
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
|
"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.status": "Status",
|
||||||
"ui.admin.ory_ssot.summary.updated_at": "Updated at",
|
"ui.admin.ory_ssot.summary.updated_at": "Updated at",
|
||||||
"ui.admin.ory_ssot.title": "Ory SSOT System",
|
"ui.admin.ory_ssot.title": "Ory SSOT System",
|
||||||
"msg.admin.ory_ssot.flush_confirm":
|
"msg.admin.ory_ssot.flush_confirm": "Flush only Redis identity cache keys?",
|
||||||
"Flush only Redis identity cache keys?",
|
|
||||||
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush failed.",
|
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush failed.",
|
||||||
"msg.admin.ory_ssot.flush_success":
|
"msg.admin.ory_ssot.flush_success":
|
||||||
"Flushed {{count}} Redis identity cache keys.",
|
"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="/tenants"]')).toBeVisible();
|
||||||
await expect(page.locator('a[href="/api-keys"]')).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="/audit-logs"]')).toBeVisible();
|
||||||
await expect(
|
await expect(page.locator('a[href="/system/ory-ssot"]')).toBeVisible();
|
||||||
page.locator('a[href="/system/ory-ssot"]'),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('a[href="/system/data-integrity"]'),
|
page.locator('a[href="/system/data-integrity"]'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ test.describe("Tenants Management", () => {
|
|||||||
headers: {
|
headers: {
|
||||||
"content-type": "text/csv; charset=utf-8",
|
"content-type": "text/csv; charset=utf-8",
|
||||||
"content-disposition": 'attachment; filename="tenant-users.csv"',
|
"content-disposition": 'attachment; filename="tenant-users.csv"',
|
||||||
|
"access-control-expose-headers": "content-disposition",
|
||||||
},
|
},
|
||||||
body: "email,name\nmember@example.com,Member User\n",
|
body: "email,name\nmember@example.com,Member User\n",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
|||||||
"../common/**/node_modules/**",
|
"../common/**/node_modules/**",
|
||||||
"../common/.pnpm-store/**",
|
"../common/.pnpm-store/**",
|
||||||
`${commonRoot}/theme/**`,
|
`${commonRoot}/theme/**`,
|
||||||
|
`${commonRoot}/core/components/audit/AuditLogTable.tsx`,
|
||||||
`${commonRoot}/core/pagination/*.worker.ts`,
|
`${commonRoot}/core/pagination/*.worker.ts`,
|
||||||
`${commonRoot}/core/query/queryClient.ts`,
|
`${commonRoot}/core/query/queryClient.ts`,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ func TestAuditWorksmobileDuplicatePhoneCountryCodesReportsAndFixes(t *testing.T)
|
|||||||
output := &strings.Builder{}
|
output := &strings.Builder{}
|
||||||
|
|
||||||
count, err := auditWorksmobileDuplicatePhoneCountryCodes(context.Background(), output, true, client)
|
count, err := auditWorksmobileDuplicatePhoneCountryCodes(context.Background(), output, true, client)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("auditWorksmobileDuplicatePhoneCountryCodes returned error: %v", err)
|
t.Fatalf("auditWorksmobileDuplicatePhoneCountryCodes returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type DevHandler struct {
|
|||||||
KetoOutbox repository.KetoOutboxRepository
|
KetoOutbox repository.KetoOutboxRepository
|
||||||
RPSvc service.RelyingPartyService
|
RPSvc service.RelyingPartyService
|
||||||
TenantSvc service.TenantService
|
TenantSvc service.TenantService
|
||||||
DeveloperSvc *service.DeveloperService
|
DeveloperSvc developerRequestService
|
||||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
||||||
RPUsageQueries domain.RPUsageQueryRepository
|
RPUsageQueries domain.RPUsageQueryRepository
|
||||||
Auth interface {
|
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(
|
func NewDevHandler(
|
||||||
redis domain.RedisRepository,
|
redis domain.RedisRepository,
|
||||||
secretRepo domain.ClientSecretRepository,
|
secretRepo domain.ClientSecretRepository,
|
||||||
@@ -426,7 +436,28 @@ func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domai
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", tenantID, "grant_dev_permissions")
|
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 {
|
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)
|
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 {
|
type devMockRedisRepo struct {
|
||||||
data map[string]string
|
data map[string]string
|
||||||
}
|
}
|
||||||
@@ -1521,6 +1569,66 @@ func TestCreateClient_ApprovedDeveloperCanCreatePrivateClient(t *testing.T) {
|
|||||||
mockKeto.AssertExpectations(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) {
|
func TestGrantCreatorAdminRelation_FallsBackToOutboxOnImmediateFailure(t *testing.T) {
|
||||||
mockKeto := new(devMockKetoService)
|
mockKeto := new(devMockKetoService)
|
||||||
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "global", "manage_all").Return(false, nil).Maybe()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"gorm.io/gorm"
|
"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)
|
roots := make(map[string]bool)
|
||||||
for _, id := range baseTenantIDs {
|
for _, id := range baseTenantIDs {
|
||||||
roots[findRoot(id)] = true
|
roots[findTenantRootID(parentMap, id)] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter tenants that belong to the same tree family
|
// Filter tenants that belong to the same tree family
|
||||||
for _, t := range allTenants {
|
for _, t := range allTenants {
|
||||||
if roots[findRoot(t.ID)] {
|
if roots[findTenantRootID(parentMap, t.ID)] {
|
||||||
tenants = append(tenants, t)
|
tenants = append(tenants, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2774,6 +2764,14 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
|||||||
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
|
||||||
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
|
||||||
ttl := orgChartSnapshotCacheTTL()
|
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 cacheMode == "redis" && h.OrgChartCache != nil {
|
||||||
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
|
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()),
|
TTLSeconds: int(ttl.Seconds()),
|
||||||
}
|
}
|
||||||
c.Set("X-Orgfront-Cache", "HIT")
|
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)
|
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)
|
snapshot, err := h.buildOrgChartSnapshot(c.Context(), profile)
|
||||||
if err != nil {
|
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())
|
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
|
||||||
}
|
}
|
||||||
snapshot.Cache = orgChartSnapshotCacheInfo{
|
snapshot.Cache = orgChartSnapshotCacheInfo{
|
||||||
@@ -2802,13 +2830,31 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
if cacheMode == "redis" && h.OrgChartCache != nil {
|
if cacheMode == "redis" && h.OrgChartCache != nil {
|
||||||
if raw, err := json.Marshal(snapshot); err == 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")
|
c.Set("X-Orgfront-Cache", "MISS")
|
||||||
} else {
|
} else {
|
||||||
c.Set("X-Orgfront-Cache", "BYPASS")
|
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)
|
return c.JSON(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2880,27 +2926,16 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi
|
|||||||
parentMap[tenant.ID] = *tenant.ParentID
|
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)
|
roots := make(map[string]bool)
|
||||||
for _, id := range baseTenantIDs {
|
for _, id := range baseTenantIDs {
|
||||||
if strings.TrimSpace(id) != "" {
|
if strings.TrimSpace(id) != "" {
|
||||||
roots[findRoot(id)] = true
|
roots[findTenantRootID(parentMap, id)] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tenants := make([]domain.Tenant, 0, len(allTenants))
|
tenants := make([]domain.Tenant, 0, len(allTenants))
|
||||||
for _, tenant := range allTenants {
|
for _, tenant := range allTenants {
|
||||||
if roots[findRoot(tenant.ID)] {
|
if roots[findTenantRootID(parentMap, tenant.ID)] {
|
||||||
tenants = append(tenants, tenant)
|
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)
|
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 {
|
func orgChartSnapshotCacheTTL() time.Duration {
|
||||||
const defaultTTL = 5 * time.Minute
|
const defaultTTL = 5 * time.Minute
|
||||||
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
|
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 {
|
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||||
token := c.Query("token")
|
token := c.Query("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
slog.Warn("public orgchart rejected missing token")
|
||||||
return errorJSON(c, fiber.StatusUnauthorized, "share token is required")
|
return errorJSON(c, fiber.StatusUnauthorized, "share token is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
link, err := h.SharedLink.ValidateToken(c.Context(), token)
|
link, err := h.SharedLink.ValidateToken(c.Context(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Warn("public orgchart token validation failed",
|
||||||
|
"token_length", len(token),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
|
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
|
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
|
||||||
if err != nil {
|
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())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3016,24 +3091,12 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
findRoot := func(id string) string {
|
sharedRootID := findTenantRootID(parentMap, link.TenantID)
|
||||||
curr := id
|
|
||||||
for {
|
|
||||||
p, exists := parentMap[curr]
|
|
||||||
if !exists || p == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
curr = p
|
|
||||||
}
|
|
||||||
return curr
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedRootID := findRoot(link.TenantID)
|
|
||||||
var filteredTenants []domain.Tenant
|
var filteredTenants []domain.Tenant
|
||||||
var tenantIDs []string
|
var tenantIDs []string
|
||||||
|
|
||||||
for _, t := range allTenants {
|
for _, t := range allTenants {
|
||||||
if findRoot(t.ID) == sharedRootID {
|
if findTenantRootID(parentMap, t.ID) == sharedRootID {
|
||||||
filteredTenants = append(filteredTenants, t)
|
filteredTenants = append(filteredTenants, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3076,6 +3139,13 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
|||||||
tenantSummaries = append(tenantSummaries, mapTenantSummary(t))
|
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{
|
return c.JSON(fiber.Map{
|
||||||
"tenants": tenantSummaries,
|
"tenants": tenantSummaries,
|
||||||
"users": publicUsers,
|
"users": publicUsers,
|
||||||
|
|||||||
@@ -405,6 +405,68 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
|
|||||||
mockUsers.AssertExpectations(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) {
|
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
@@ -740,6 +802,25 @@ func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
|
|||||||
return true
|
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) {
|
func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
|
|||||||
@@ -197,3 +197,10 @@ pending = "Pending"
|
|||||||
success = "Success"
|
success = "Success"
|
||||||
unchanged = "Unchanged"
|
unchanged = "Unchanged"
|
||||||
updated = "Updated"
|
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 = "성공"
|
success = "성공"
|
||||||
unchanged = "동일"
|
unchanged = "동일"
|
||||||
updated = "수정"
|
updated = "수정"
|
||||||
|
|
||||||
|
[ui.common]
|
||||||
|
searching = "검색 중..."
|
||||||
|
|
||||||
|
[ui.common.custom_claim_permission]
|
||||||
|
admin_only = "관리자만 가능"
|
||||||
|
user_and_admin = "사용자와 관리자"
|
||||||
|
|||||||
@@ -197,3 +197,10 @@ pending = ""
|
|||||||
success = ""
|
success = ""
|
||||||
unchanged = ""
|
unchanged = ""
|
||||||
updated = ""
|
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">
|
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
|
||||||
{tabOrder.map((tab) => {
|
{tabOrder.map((tab) => {
|
||||||
const isActive = tab.key === activeTab;
|
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 ? (
|
return isActive ? (
|
||||||
<span
|
<span
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
|
|||||||
|
|
||||||
await expect(tabs).toHaveText([
|
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 ({
|
test("adds and removes allowed tenants with UUID copy evidence", async ({
|
||||||
context,
|
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, 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 = {
|
const state = {
|
||||||
clients: [
|
clients: [
|
||||||
@@ -82,7 +94,9 @@ test.describe("DevFront client tenant access settings", () => {
|
|||||||
).toContainText(existingTenantId);
|
).toContainText(existingTenantId);
|
||||||
await page.getByTestId(`allowed-tenant-copy-${existingTenantId}`).click();
|
await page.getByTestId(`allowed-tenant-copy-${existingTenantId}`).click();
|
||||||
await expect
|
await expect
|
||||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
.poll(() =>
|
||||||
|
page.evaluate(() => window.localStorage.getItem("__e2e_copied_text")),
|
||||||
|
)
|
||||||
.toBe(existingTenantId);
|
.toBe(existingTenantId);
|
||||||
|
|
||||||
await page
|
await page
|
||||||
|
|||||||
@@ -2961,3 +2961,79 @@ tenant_slug = "Tenant slug"
|
|||||||
"ui.admin.tenants.data_mgmt" = "temp"
|
"ui.admin.tenants.data_mgmt" = "temp"
|
||||||
"ui.admin.tenants.toggle_status" = "temp"
|
"ui.admin.tenants.toggle_status" = "temp"
|
||||||
"ui.admin.users.data_mgmt" = "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.data_mgmt" = "temp"
|
||||||
"ui.admin.tenants.toggle_status" = "temp"
|
"ui.admin.tenants.toggle_status" = "temp"
|
||||||
"ui.admin.users.data_mgmt" = "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.data_mgmt" = "temp"
|
||||||
"ui.admin.tenants.toggle_status" = "temp"
|
"ui.admin.tenants.toggle_status" = "temp"
|
||||||
"ui.admin.users.data_mgmt" = "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;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OrgChartLoadErrorDiagnostics = {
|
||||||
|
cacheMode: "redis" | "public";
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
route: string;
|
||||||
|
status: number | null;
|
||||||
|
tenantId: string;
|
||||||
|
};
|
||||||
|
|
||||||
type OrgSelectionDescendantOption = {
|
type OrgSelectionDescendantOption = {
|
||||||
depth: 1 | 2;
|
depth: 1 | 2;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1215,6 +1224,36 @@ function normalizeOrgSlug(value: unknown) {
|
|||||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
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[] {
|
function getUserOrgAppointmentRefs(user: UserSummary): UserOrgAppointmentRef[] {
|
||||||
const rawAppointments = user.metadata?.additionalAppointments;
|
const rawAppointments = user.metadata?.additionalAppointments;
|
||||||
if (!Array.isArray(rawAppointments)) return [];
|
if (!Array.isArray(rawAppointments)) return [];
|
||||||
@@ -1443,7 +1482,10 @@ export function TenantOrgChartPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootNodes = buildTenantFullTree(
|
const rootNodes = buildTenantFullTree(
|
||||||
filterSystemGlobalTenants(orgChartSnapshotQuery.data.tenants, visibilityMode),
|
filterSystemGlobalTenants(
|
||||||
|
orgChartSnapshotQuery.data.tenants,
|
||||||
|
visibilityMode,
|
||||||
|
),
|
||||||
).subTree;
|
).subTree;
|
||||||
const membershipRootNodes = buildTenantFullTree(
|
const membershipRootNodes = buildTenantFullTree(
|
||||||
filterOrgChartMembershipTenants(orgChartSnapshotQuery.data.tenants),
|
filterOrgChartMembershipTenants(orgChartSnapshotQuery.data.tenants),
|
||||||
@@ -1601,6 +1643,24 @@ export function TenantOrgChartPage() {
|
|||||||
const isError = shareToken
|
const isError = shareToken
|
||||||
? publicQuery.isError
|
? publicQuery.isError
|
||||||
: orgChartSnapshotQuery.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 totalUsers = React.useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -1619,9 +1679,12 @@ export function TenantOrgChartPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
|
const errorMessage = shareToken
|
||||||
|
? "조직도를 불러올 수 없거나 만료된 링크입니다."
|
||||||
|
: "조직도를 불러올 수 없습니다. 로그인 상태와 조직 권한을 확인해 주세요.";
|
||||||
return (
|
return (
|
||||||
<div className="p-8 text-center text-red-500">
|
<div className="p-8 text-center text-red-500">
|
||||||
조직도를 불러올 수 없거나 만료된 링크입니다.
|
{errorMessage}
|
||||||
</div>
|
</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;
|
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()) {
|
for (const node of tenantMap.values()) {
|
||||||
if (!node.parentId) {
|
if (!node.parentId || node.parentId === node.id) {
|
||||||
visitedForCalc.clear();
|
visitedForCalc.clear();
|
||||||
calculateRecursive(node);
|
calculateRecursive(node);
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,8 @@ export function buildTenantFullTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no rootId, return all top-level roots as subTree
|
// 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 };
|
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");
|
await page.goto("/chart");
|
||||||
|
|
||||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
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 })).toBeVisible();
|
await expect(svg.getByText("삼안", { exact: true })).toBeVisible();
|
||||||
await expect(svg.getByText("공공기관", { exact: true })).toHaveCount(0);
|
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",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tenants: [
|
tenants: [
|
||||||
{
|
tenant(
|
||||||
...tenant("hanmac-family-id", "한맥가족", "hanmac-family"),
|
"hanmac-family-id",
|
||||||
type: "COMPANY_GROUP",
|
"한맥가족",
|
||||||
},
|
"hanmac-family",
|
||||||
|
"hanmac-family-id",
|
||||||
|
"COMPANY_GROUP",
|
||||||
|
),
|
||||||
tenant("saman-id", "삼안", "saman", "hanmac-family-id", "COMPANY"),
|
tenant("saman-id", "삼안", "saman", "hanmac-family-id", "COMPANY"),
|
||||||
tenant("saman-platform-id", "플랫폼팀", "saman-platform", "saman-id"),
|
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();
|
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 ({
|
test("org chart places GPDTDC representative users on visible leaf appointments", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user