([
+ "client",
+ "client-claims",
+ ]);
+ expect(cached?.client.metadata?.id_token_claims).toEqual([
+ {
+ namespace: "rp_claims",
+ key: "new_claim",
+ value: "A",
+ valueType: "text",
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ]);
+ });
+});
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx
index ec409582..994951e0 100644
--- a/devfront/src/features/clients/ClientGeneralPage.tsx
+++ b/devfront/src/features/clients/ClientGeneralPage.tsx
@@ -84,6 +84,7 @@ interface IdTokenClaimItem {
key: string;
value: string;
valueType: ClaimValueType;
+ nullable: boolean;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
}
@@ -169,6 +170,7 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem {
key: "",
value: "",
valueType: "text",
+ nullable: false,
readPermission: "admin_only",
writePermission: "admin_only",
};
@@ -217,6 +219,7 @@ function readIdTokenClaimsMetadata(
key: keyValue,
value: valueValue,
valueType: valueTypeValue,
+ nullable: record.nullable === true,
readPermission: isCustomClaimPermission(record.readPermission)
? record.readPermission
: "admin_only",
@@ -231,8 +234,12 @@ function readIdTokenClaimsMetadata(
function normalizeClaimPreviewValue(
value: string,
valueType: ClaimValueType,
+ nullable: boolean,
): unknown {
const trimmed = value.trim();
+ if (nullable && trimmed === "") {
+ return null;
+ }
if (valueType === "number") {
if (trimmed === "") return "";
const parsed = Number(trimmed);
@@ -284,7 +291,11 @@ function buildIdTokenClaimsPreview(
continue;
}
- rpClaims[key] = normalizeClaimPreviewValue(item.value, item.valueType);
+ rpClaims[key] = normalizeClaimPreviewValue(
+ item.value,
+ item.valueType,
+ item.nullable,
+ );
}
if (Object.keys(rpClaims).length > 0) {
@@ -755,6 +766,25 @@ function ClientGeneralPage() {
);
};
+ const setIdTokenClaimPermissionAllowed = (
+ id: string,
+ field: "readPermission" | "writePermission",
+ allowed: boolean,
+ ) => {
+ const permission = allowed ? "user_and_admin" : "admin_only";
+ setIdTokenClaims((current) =>
+ current.map((claim) => {
+ if (claim.id !== id) {
+ return claim;
+ }
+ return {
+ ...claim,
+ [field]: permission,
+ };
+ }),
+ );
+ };
+
const removeIdTokenClaim = (id: string) => {
setIdTokenClaims((current) => current.filter((claim) => claim.id !== id));
};
@@ -1090,6 +1120,7 @@ function ClientGeneralPage() {
return createClient(payload);
}
+ await queryClient.cancelQueries({ queryKey: ["client", clientId] });
const updated = await updateClient(clientId as string, payload);
if (status !== initialStatus) {
await updateClientStatus(clientId as string, status);
@@ -1097,6 +1128,10 @@ function ClientGeneralPage() {
return updated;
},
onSuccess: (result) => {
+ const resultClientId = result?.client?.id ?? clientId;
+ if (resultClientId) {
+ queryClient.setQueryData(["client", resultClientId], result);
+ }
queryClient.invalidateQueries({ queryKey: ["clients"] });
if (status !== initialStatus) {
setInitialStatus(status);
@@ -2109,20 +2144,26 @@ function ClientGeneralPage() {
|
{t(
- "ui.dev.clients.general.id_token_claims.table.read_permission",
+ "ui.dev.clients.general.id_token_claims.table.nullable",
+ "Nullable",
+ )}
+ |
+
+ {t(
+ "ui.dev.clients.general.id_token_claims.table.read_user_allowed",
"Read",
)}
|
{t(
- "ui.dev.clients.general.id_token_claims.table.write_permission",
+ "ui.dev.clients.general.id_token_claims.table.write_user_allowed",
"Write",
)}
|
{t(
- "ui.dev.clients.general.id_token_claims.table.value",
- "Value",
+ "ui.dev.clients.general.id_token_claims.table.default_value",
+ "Default Value",
)}
|
@@ -2227,66 +2268,65 @@ function ClientGeneralPage() {
|
- |
- |
+
+
+
+ setIdTokenClaimPermissionAllowed(
+ claim.id,
+ "writePermission",
+ checked,
+ )
+ }
+ aria-label={t(
+ "ui.dev.clients.general.id_token_claims.write_user_allowed_label",
+ "Write 사용자 허용",
)}
-
-
+ disabled={isGeneralSettingsReadOnly}
+ />
+
|
diff --git a/devfront/src/features/clients/ClientsPage.test.tsx b/devfront/src/features/clients/ClientsPage.test.tsx
index ff792cd6..14918c4b 100644
--- a/devfront/src/features/clients/ClientsPage.test.tsx
+++ b/devfront/src/features/clients/ClientsPage.test.tsx
@@ -182,6 +182,52 @@ async function waitForTextContent(container: HTMLElement, text: string) {
}
describe("ClientsPage", () => {
+ it("does not show the legacy tenant scope label for unrestricted clients", async () => {
+ fetchClientsMock.mockResolvedValue({
+ items: [
+ {
+ ...makeClients(1)[0],
+ name: "Unrestricted App",
+ metadata: {
+ tenant_access_restricted: false,
+ allowed_tenants: [],
+ },
+ },
+ ],
+ limit: 100,
+ offset: 0,
+ });
+
+ const container = await renderPage();
+
+ expect(container.textContent).toContain("Unrestricted App");
+ expect(container.textContent).not.toContain("Tenant-scoped");
+ expect(container.textContent).not.toContain("Tenant-limited");
+ });
+
+ it("shows Tenant-limited only when client tenant access is restricted", async () => {
+ fetchClientsMock.mockResolvedValue({
+ items: [
+ {
+ ...makeClients(1)[0],
+ name: "Limited App",
+ metadata: {
+ tenant_access_restricted: true,
+ allowed_tenants: ["tenant-1"],
+ },
+ },
+ ],
+ limit: 100,
+ offset: 0,
+ });
+
+ const container = await renderPage();
+
+ expect(container.textContent).toContain("Limited App");
+ expect(container.textContent).toContain("Tenant-limited");
+ expect(container.textContent).not.toContain("Tenant-scoped");
+ });
+
it("expands the list and applies search filters", async () => {
fetchClientsMock.mockResolvedValue({
items: makeClients(6),
diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx
index e183bded..6552c03e 100644
--- a/devfront/src/features/clients/ClientsPage.tsx
+++ b/devfront/src/features/clients/ClientsPage.tsx
@@ -59,6 +59,19 @@ import { ClientLogo } from "./components/ClientLogo";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
const clientListPreviewCount = 5;
+function isClientTenantLimited(client: ClientSummary) {
+ const metadata = client.metadata ?? {};
+ if (metadata.tenant_access_restricted === true) {
+ return true;
+ }
+ if (!Array.isArray(metadata.allowed_tenants)) {
+ return false;
+ }
+ return metadata.allowed_tenants.some(
+ (tenantId) => typeof tenantId === "string" && tenantId.trim() !== "",
+ );
+}
+
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
@@ -529,14 +542,16 @@ function ClientsPage() {
{client.name ||
t("ui.dev.clients.untitled", "Untitled")}
-
-
- {t(
- "ui.dev.clients.tenant_scoped",
- "Tenant-scoped",
- )}
-
-
+ {isClientTenantLimited(client) && (
+
+
+ {t(
+ "ui.dev.clients.tenant_limited",
+ "Tenant-limited",
+ )}
+
+
+ )}
diff --git a/devfront/src/features/coverage/pageSmoke.test.tsx b/devfront/src/features/coverage/pageSmoke.test.tsx
index 7d653714..20e6746e 100644
--- a/devfront/src/features/coverage/pageSmoke.test.tsx
+++ b/devfront/src/features/coverage/pageSmoke.test.tsx
@@ -492,7 +492,8 @@ describe("devfront coverage smoke pages", () => {
expect(settings.textContent).not.toContain("top-level");
expect(settings.textContent).toContain("Date");
expect(settings.textContent).toContain("Datetime");
- expect(settings.textContent).toContain("관리자만 가능");
+ expect(settings.textContent).toContain("Read");
+ expect(settings.textContent).toContain("Write");
const consents = await renderPage(, {
path: "/clients/:id/consents",
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index 3c97cb22..e5d0eaf1 100644
--- a/devfront/src/locales/en.toml
+++ b/devfront/src/locales/en.toml
@@ -1431,7 +1431,7 @@ user = "General User"
[ui.dev.clients]
new = "Add Connected Application"
search_placeholder = "Search by app name or ID..."
-tenant_scoped = "Tenant-scoped"
+tenant_limited = "Tenant-limited"
untitled = "Untitled"
[ui.dev.clients.recent_changes]
@@ -1617,6 +1617,17 @@ preview_title = "Saved JSON Preview"
namespace_label = "Claim namespace"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
+nullable_label = "Nullable"
+read_user_allowed_label = "Read user allowed"
+write_user_allowed_label = "Write user allowed"
+table.key = "Claim Key"
+table.namespace = "Namespace"
+table.value_type = "Value Type"
+table.nullable = "Nullable"
+table.read_user_allowed = "Read"
+table.write_user_allowed = "Write"
+table.default_value = "Default Value"
+table.delete = "Delete"
value_type_label = "Claim value type"
value_type_text = "Text"
value_type_number = "Number"
@@ -1624,7 +1635,7 @@ value_type_boolean = "Boolean"
value_type_array = "Array"
value_type_object = "Object"
key_placeholder = "e.g. locale"
-value_placeholder = "Enter the claim value"
+value_placeholder = "Enter the default value"
[ui.dev.clients.general.security]
private = "Server Side App"
diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml
index 4f7920c6..96593843 100644
--- a/devfront/src/locales/ko.toml
+++ b/devfront/src/locales/ko.toml
@@ -1431,7 +1431,7 @@ user = "일반 사용자"
[ui.dev.clients]
new = "연동 앱 추가"
search_placeholder = "연동 앱 이름/ID로 검색..."
-tenant_scoped = "Tenant-scoped"
+tenant_limited = "Tenant-limited"
untitled = "Untitled"
[ui.dev.clients.recent_changes]
@@ -1616,6 +1616,17 @@ preview_title = "저장 JSON 미리보기"
namespace_label = "Claim 네임스페이스"
namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims"
+nullable_label = "Null 허용"
+read_user_allowed_label = "Read 사용자 허용"
+write_user_allowed_label = "Write 사용자 허용"
+table.key = "Claim Key"
+table.namespace = "Namespace"
+table.value_type = "Value Type"
+table.nullable = "Null 허용"
+table.read_user_allowed = "Read"
+table.write_user_allowed = "Write"
+table.default_value = "기본값"
+table.delete = "삭제"
value_type_label = "Claim 값 타입"
value_type_text = "텍스트"
value_type_number = "숫자"
@@ -1623,7 +1634,7 @@ value_type_boolean = "불리언"
value_type_array = "배열"
value_type_object = "객체"
key_placeholder = "예: locale"
-value_placeholder = "Claim 값을 입력하세요"
+value_placeholder = "기본값을 입력하세요"
[ui.dev.clients.general.security]
private = "Server side App"
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml
index f3e69b54..d0969cb0 100644
--- a/devfront/src/locales/template.toml
+++ b/devfront/src/locales/template.toml
@@ -1484,7 +1484,7 @@ user = ""
[ui.dev.clients]
new = ""
search_placeholder = ""
-tenant_scoped = ""
+tenant_limited = ""
untitled = ""
[ui.dev.clients.recent_changes]
@@ -1665,6 +1665,17 @@ preview_title = ""
namespace_label = ""
namespace_top_level = ""
namespace_rp_claims = ""
+nullable_label = ""
+read_user_allowed_label = ""
+write_user_allowed_label = ""
+table.key = ""
+table.namespace = ""
+table.value_type = ""
+table.nullable = ""
+table.read_user_allowed = ""
+table.write_user_allowed = ""
+table.default_value = ""
+table.delete = ""
value_type_label = ""
value_type_text = ""
value_type_number = ""
diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts
index 824d7539..abd350fb 100644
--- a/devfront/tests/clients.spec.ts
+++ b/devfront/tests/clients.spec.ts
@@ -50,6 +50,43 @@ test("clients page loads correctly", async ({ page }) => {
).toBeVisible();
});
+test("clients page shows Tenant-limited only for tenant access restricted RP", async ({
+ page,
+}) => {
+ await seedAuth(page, "super_admin");
+ await installDevApiMock(page, {
+ clients: [
+ makeClient("client-limited", {
+ name: "Limited RP",
+ createdAt: "2026-05-02T00:00:00.000Z",
+ metadata: {
+ tenant_access_restricted: true,
+ allowed_tenants: ["tenant-1"],
+ },
+ }),
+ makeClient("client-open", {
+ name: "Open RP",
+ createdAt: "2026-05-01T00:00:00.000Z",
+ metadata: {
+ tenant_access_restricted: false,
+ allowed_tenants: [],
+ },
+ }),
+ ],
+ consents: [] as Consent[],
+ auditLogsByCursor: undefined,
+ });
+
+ await page.goto("/clients");
+
+ const limitedRow = page.locator("tbody tr", { hasText: "Limited RP" });
+ await expect(limitedRow).toContainText("Tenant-limited");
+
+ const openRow = page.locator("tbody tr", { hasText: "Open RP" });
+ await expect(openRow).not.toContainText("Tenant-limited");
+ await expect(page.getByText("Tenant-scoped")).toHaveCount(0);
+});
+
test("overview page shows recent RP changes", async ({ page }) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
diff --git a/devfront/tests/devfront-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts
new file mode 100644
index 00000000..e3a45fe0
--- /dev/null
+++ b/devfront/tests/devfront-client-claims-cache.spec.ts
@@ -0,0 +1,63 @@
+import { expect, test } from "@playwright/test";
+import {
+ type Consent,
+ installDevApiMock,
+ makeClient,
+ seedAuth,
+} from "./helpers/devfront-fixtures";
+import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
+
+test.describe("DevFront RP claim cache", () => {
+ test.beforeEach(async ({ page }) => {
+ await installDevFrontStaticRoutes(page);
+ await seedAuth(page, "super_admin");
+ });
+
+ test("keeps saved RP claim value visible after saving", async ({ page }) => {
+ const state = {
+ clients: [
+ makeClient("client-claims", {
+ name: "Claims app",
+ metadata: {
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "old_claim",
+ value: "A",
+ valueType: "text",
+ readPermission: "admin_only",
+ writePermission: "admin_only",
+ },
+ ],
+ },
+ }),
+ ],
+ consents: [] as Consent[],
+ auditLogsByCursor: undefined,
+ mockRole: "super_admin",
+ };
+ await installDevApiMock(page, state);
+
+ await page.goto("http://devfront.test/clients/client-claims/settings");
+
+ const claimKeyInput = page
+ .getByPlaceholder(/e\.g\. locale|예: locale/i)
+ .first();
+ await expect(claimKeyInput).toHaveValue("old_claim");
+
+ await claimKeyInput.fill("new_claim");
+ await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
+
+ await expect
+ .poll(
+ () =>
+ (
+ state.clients[0]?.metadata?.id_token_claims as
+ | Array<{ key?: string }>
+ | undefined
+ )?.[0]?.key,
+ )
+ .toBe("new_claim");
+ await expect(claimKeyInput).toHaveValue("new_claim");
+ });
+});
diff --git a/devfront/tests/devfront-clients-lifecycle.spec.ts b/devfront/tests/devfront-clients-lifecycle.spec.ts
index d8846e09..16e4c0cc 100644
--- a/devfront/tests/devfront-clients-lifecycle.spec.ts
+++ b/devfront/tests/devfront-clients-lifecycle.spec.ts
@@ -155,14 +155,21 @@ test.describe("DevFront clients lifecycle", () => {
.getByLabel(/Claim value type|Claim 값 타입/i)
.first()
.selectOption("date");
+ await expect(
+ page.getByRole("columnheader", { name: /Default Value|기본값/i }),
+ ).toBeVisible();
await page
- .getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
+ .getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first()
.fill("2026-06-09");
await page
- .getByLabel(/읽기 권한|Read permission/i)
+ .getByLabel(/Nullable|Null 허용/i)
.first()
- .selectOption("user_and_admin");
+ .click();
+ await page
+ .getByLabel(/Read 사용자 허용|Read user allowed/i)
+ .first()
+ .click();
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
await page
@@ -174,7 +181,7 @@ test.describe("DevFront clients lifecycle", () => {
.nth(1)
.selectOption("number");
await page
- .getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
+ .getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.nth(1)
.fill("2");
@@ -238,6 +245,7 @@ test.describe("DevFront clients lifecycle", () => {
key?: string;
value?: string;
valueType?: string;
+ nullable?: boolean;
readPermission?: string;
writePermission?: string;
}>
@@ -245,6 +253,18 @@ test.describe("DevFront clients lifecycle", () => {
)?.[0]?.valueType,
)
.toBe("date");
+ await expect
+ .poll(
+ () =>
+ (
+ state.clients[0]?.metadata?.id_token_claims as
+ | Array<{
+ nullable?: boolean;
+ }>
+ | undefined
+ )?.[0]?.nullable,
+ )
+ .toBe(true);
await expect
.poll(
() =>
@@ -313,18 +333,25 @@ test.describe("DevFront clients lifecycle", () => {
page.getByPlaceholder(/e\.g\. locale|예: locale/i).nth(1),
).toHaveValue("tier");
await expect(
- page.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i),
+ page.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i),
).toHaveCount(2);
await expect(
page
- .getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
+ .getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first(),
).toHaveValue("2026-06-09");
await expect(
page
- .getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i)
+ .getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.nth(1),
).toHaveValue("2");
+ await expect(page.getByLabel(/Nullable|Null 허용/i).first()).toBeChecked();
+ await expect(
+ page.getByLabel(/Read 사용자 허용|Read user allowed/i).first(),
+ ).toBeChecked();
+ await expect(
+ page.getByLabel(/Write 사용자 허용|Write user allowed/i).first(),
+ ).not.toBeChecked();
});
test("headless login uses jwks uri only and shows cache actions", async ({
diff --git a/devfront/tests/devfront-consents.spec.ts b/devfront/tests/devfront-consents.spec.ts
index 57cd9a53..d7e39755 100644
--- a/devfront/tests/devfront-consents.spec.ts
+++ b/devfront/tests/devfront-consents.spec.ts
@@ -40,6 +40,18 @@ test.describe("DevFront consents", () => {
valueType: "datetime",
value: "2026-06-09T09:30",
},
+ {
+ namespace: "rp_claims",
+ key: "active_member",
+ valueType: "boolean",
+ value: "true",
+ },
+ {
+ namespace: "rp_claims",
+ key: "score",
+ valueType: "number",
+ value: "1",
+ },
],
},
}),
@@ -78,9 +90,14 @@ test.describe("DevFront consents", () => {
await expect(page.getByText("RP Custom Claims")).toBeVisible();
await expect(page.getByText("contract_date")).toBeVisible();
await expect(page.getByText("approved_at")).toBeVisible();
+ await expect(page.getByText("active_member")).toBeVisible();
await expect(page.locator('input[type="date"]')).toHaveValue("2026-06-09");
await page.locator('input[type="date"]').fill("2026-06-10");
await page.locator('input[type="datetime-local"]').fill("2026-06-09T10:30");
+ await page
+ .getByLabel(/active_member.*boolean|boolean.*active_member/i)
+ .selectOption("false");
+ await page.getByLabel(/score.*number|number.*score/i).fill("42");
await page
.getByLabel(/쓰기 권한|Write permission/i)
.first()
@@ -92,6 +109,10 @@ test.describe("DevFront consents", () => {
await expect
.poll(() => state.consents[0]?.rpMetadata?.approved_at)
.toBe("2026-06-09T10:30");
+ await expect
+ .poll(() => state.consents[0]?.rpMetadata?.active_member)
+ .toBe(false);
+ await expect.poll(() => state.consents[0]?.rpMetadata?.score).toBe(42);
await expect
.poll(
() =>
diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts
index 0d88a5b6..706e9ab6 100644
--- a/devfront/tests/helpers/devfront-fixtures.ts
+++ b/devfront/tests/helpers/devfront-fixtures.ts
@@ -466,6 +466,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
createdAt: client.createdAt,
redirectUris: client.redirectUris,
scopes: client.scopes,
+ metadata: client.metadata ?? {},
})),
limit: 50,
offset: 0,
@@ -612,6 +613,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
+ headlessJwksCache: found.headlessJwksCache,
});
}
@@ -635,6 +637,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
+ headlessJwksCache: found.headlessJwksCache,
});
}
@@ -720,6 +723,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
token: "https://issuer/oauth2/token",
userinfo: "https://issuer/userinfo",
},
+ headlessJwksCache: found.headlessJwksCache,
});
}
diff --git a/devfront/tests/helpers/static-devfront.ts b/devfront/tests/helpers/static-devfront.ts
new file mode 100644
index 00000000..3fb12f97
--- /dev/null
+++ b/devfront/tests/helpers/static-devfront.ts
@@ -0,0 +1,93 @@
+import { readFile, stat } from "node:fs/promises";
+import { extname, join, normalize, resolve } from "node:path";
+import type { Page } from "@playwright/test";
+
+const contentTypes: Record = {
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".ico": "image/x-icon",
+ ".js": "application/javascript; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".map": "application/json; charset=utf-8",
+ ".mjs": "application/javascript; charset=utf-8",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".txt": "text/plain; charset=utf-8",
+ ".webp": "image/webp",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+};
+
+function safeDistPath(distDir: string, pathname: string) {
+ const decoded = decodeURIComponent(pathname);
+ const relative = decoded.replace(/^\/+/, "");
+ const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, "");
+ return join(distDir, safe);
+}
+
+async function resolveStaticFile(distDir: string, pathname: string) {
+ const indexPath = join(distDir, "index.html");
+ let filePath = safeDistPath(
+ distDir,
+ pathname === "/" ? "/index.html" : pathname,
+ );
+
+ try {
+ const fileStat = await stat(filePath);
+ if (fileStat.isDirectory()) {
+ filePath = join(filePath, "index.html");
+ }
+ } catch {
+ filePath = indexPath;
+ }
+
+ try {
+ return {
+ body: await readFile(filePath),
+ contentType:
+ contentTypes[extname(filePath).toLowerCase()] ??
+ "application/octet-stream",
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function installDevFrontStaticRoutes(
+ page: Page,
+ options: {
+ distDir?: string;
+ origin?: string;
+ } = {},
+) {
+ const origin = options.origin ?? "http://devfront.test";
+ const distDir = resolve(
+ options.distDir ??
+ process.env.DEVFRONT_DIST_DIR ??
+ "/tmp/baron-sso-devfront-dist",
+ );
+
+ await page.route(`${origin}/**`, async (route) => {
+ const url = new URL(route.request().url());
+ if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
+ await route.fallback();
+ return;
+ }
+
+ const file = await resolveStaticFile(distDir, url.pathname);
+ if (!file) {
+ await route.fulfill({
+ status: 500,
+ contentType: "application/json; charset=utf-8",
+ body: JSON.stringify({ error: "devfront_dist_not_found" }),
+ });
+ return;
+ }
+
+ await route.fulfill({
+ status: 200,
+ contentType: file.contentType,
+ body: file.body,
+ });
+ });
+}
diff --git a/docs/SoT_Architecture_Policy.md b/docs/SoT_Architecture_Policy.md
index 8aaef239..42cab310 100644
--- a/docs/SoT_Architecture_Policy.md
+++ b/docs/SoT_Architecture_Policy.md
@@ -1,44 +1,54 @@
-# Baron SSO Data SoT (Source of Truth) Architecture Policy
+# Baron SSO Data SoT Architecture Policy
-## 1. Core Principle: "Ory Stack is the Single Source of Truth"
-Baron SSO 시스템에서 인증(Identity), 인가(Authorization), OAuth2 위임(Delegation)의 데이터 원천은 **Ory Stack (Kratos, Keto, Hydra)** 입니다.
-Backend의 로컬 데이터베이스(PostgreSQL)는 성능 최적화, 검색, 감사(Audit), 비즈니스 메타데이터 관리를 위한 **Read-Model** 및 **Cold Storage**의 역할만 수행합니다.
+## 1. Core Principle: Ory Stack is the Single Source of Truth
+
+Baron SSO에서 인증 identity, 권한 관계, OAuth/OIDC 위임의 원장은 Ory Stack입니다.
+
+- Identity/profile 인증 원장: Ory Kratos
+- Authorization/ReBAC 원장: Ory Keto
+- OAuth/OIDC client, consent, token state 원장: Ory Hydra
+
+Backend DB는 Ory를 대체하는 원장이 아닙니다. Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 업무 데이터의 read model, 감사 로그, 처리 상태, 성능 cache 보조 데이터만 허용합니다.
+
+Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로 front 또는 외부 API에 제공합니다. frontend는 Redis나 Backend DB 복제본을 원장처럼 직접 소비하지 않습니다.
## 2. Component Policies
-### 2.1 Identity & User Profile (Ory Kratos)
-* **SoT:** Ory Kratos Identity (`traits`, `metadata_public`)
-* **Local DB (`users` Table):** **Read-Model & Search Index**
- * **목적:** 대규모 사용자 목록의 고속 검색(`LIKE`), 필터링, 정렬, 테넌트 조인(Join) 지원.
- * **동기화 전략:** `Async Write-Behind`
- * 사용자 생성/수정 API는 Kratos 처리가 성공하면 즉시 성공 응답을 반환합니다.
- * 로컬 DB 동기화는 별도 고루틴(Goroutine)에서 비동기로 수행됩니다.
- * **장애 격리:** 로컬 DB 장애가 사용자의 로그인/가입 프로세스를 차단하지 않습니다.
+### 2.1 Identity & User Profile
-### 2.2 Permissions & Relationships (Ory Keto)
-* **SoT:** Ory Keto (Relation Tuples)
-* **Local DB:**
- * 권한 판단 로직을 로컬 DB에 저장하지 않습니다.
- * `Tenant`, `TenantGroup` 등 비즈니스 객체의 **생성/삭제 이벤트**를 Keto의 관계(Relation)로 비동기 동기화합니다.
- * 모든 권한 검증(`CheckPermission`)은 반드시 Keto API를 통해 실시간으로 수행합니다.
+- Ory Kratos identity가 subject, credentials, recovery/verification address, 인증 식별자의 원장입니다.
+- Kratos identity 변경은 Backend의 중앙 `IdentityWriteService`를 경유해야 합니다.
+- Redis identity mirror는 빠른 단건/목록/검색 조회를 위한 cache입니다. stale 가능성을 API 응답에 드러내야 합니다.
+- Backend DB `users`는 Ory에 저장되지 않거나 Ory에서 필요한 방식으로 조회할 수 없는 Baron 운영 데이터의 read model입니다.
-### 2.3 OAuth2 Clients & Sessions (Ory Hydra)
-* **SoT:** Ory Hydra (OAuth2 Clients, Access/Refresh Tokens, Consent Sessions)
-* **Local DB (`client_secrets`, `client_consents`):** **Backup & Query-Model**
- * `client_secrets`: Hydra는 해시된 시크릿만 저장하므로, 시크릿 재발급 및 관리를 위한 **원본 보관소(Cold Storage)**로 사용합니다.
- * `client_consents`: Hydra API는 "특정 사용자의 동의 내역" 조회만 지원하므로, "특정 클라이언트의 전체 사용자 동의 목록"을 제공하기 위한 **조회용 모델(Query-Model)**로 사용합니다.
+### 2.2 Permissions & Relationships
+
+- 권한 판단과 관계 tuple의 원장은 Ory Keto입니다.
+- Backend DB는 relation command outbox, 처리 상태, 조직 표시/검색에 필요한 read model을 보관할 수 있습니다.
+- 보안상 중요한 권한 판정은 Backend DB metadata나 token claim만으로 수행하지 않고 Keto check를 거쳐야 합니다.
+
+### 2.3 OAuth2 Clients & Sessions
+
+- OAuth2 client, consent, token state의 프로토콜 원장은 Ory Hydra입니다.
+- `client_consents` 같은 Backend read model은 Hydra가 제공하지 않는 조회 축을 보완하기 위한 모델입니다.
+- client secret 원문처럼 Hydra가 해시만 보관하는 값은 재발급/운영 목적의 별도 보관 정책과 감사 로그를 가져야 합니다.
## 3. Data Flow & Synchronization Strategy
-### 3.1 Write Path (Command)
-1. **Request:** 클라이언트가 Backend API 요청.
-2. **Ory Exec:** Backend가 Ory 서비스(Kratos/Hydra/Keto) API를 동기(Synchronous) 호출.
-3. **Response:** Ory 성공 시 클라이언트에게 즉시 성공 응답 반환 (SoT 확정).
-4. **Sync:** Backend가 비동기(Goroutine)로 로컬 DB 테이블을 갱신.
+### 3.1 Write Path
-### 3.2 Read Path (Query)
-* **Self Context (내 정보, 내 권한):** Ory Session/Token을 통해 직접 검증하거나 Kratos/Keto를 실시간 조회 (Always Fresh).
-* **Admin Context (목록 조회, 검색):** 로컬 DB를 조회하여 빠른 응답 제공 (Eventually Consistent).
+1. 클라이언트 또는 운영 도구가 Backend API/CLI를 호출합니다.
+2. Backend가 중앙 service를 통해 Ory API를 동기 호출합니다.
+3. Ory write 성공 후 Ory ID로 재조회합니다.
+4. Redis mirror를 갱신하거나 갱신 실패 시 `stale`/`failed` 상태를 기록합니다.
+5. Ory에 저장되지 않거나 조회 불가능한 read model만 Backend DB에 갱신합니다.
+
+### 3.2 Read Path
+
+- Self context: Ory session/token 또는 Ory API를 기준으로 검증합니다.
+- Admin/list context: Backend가 Redis mirror와 허용된 read model을 조합해 cursor 기반 API로 제공합니다.
+- API response는 `identityTotal`, read model count, mirror status를 구분해야 합니다.
### 3.3 Conflict Resolution
-* 데이터 불일치가 발견될 경우, 항상 **Ory Stack의 데이터를 기준(Authority)**으로 로컬 DB를 보정(Self-healing)합니다.
+
+불일치가 발견되면 Ory Stack의 데이터를 기준으로 Redis mirror와 Backend read model을 보정합니다. Backend read model이나 token claim assembly 결과를 Ory보다 우선하는 근거로 사용하지 않습니다.
diff --git a/docs/b2b2b_dynamic_provisioning_flow.md b/docs/b2b2b_dynamic_provisioning_flow.md
index 4f6f7fa7..55c6fb65 100644
--- a/docs/b2b2b_dynamic_provisioning_flow.md
+++ b/docs/b2b2b_dynamic_provisioning_flow.md
@@ -25,7 +25,7 @@ graph TD
G -- Yes --> J[Ory Kratos 계정 생성]
%% 유저 생성 및 권한 할당
- J --> K[(Local DB 유저 레코드 생성)]
+ J --> K[(Backend read model 레코드 생성)]
K --> N[기본 권한 할당: user Keto: members 부여]
N --> O([회원가입 완료])
diff --git a/docs/backup-restore-design.md b/docs/backup-restore-design.md
index fe48b70e..b632c947 100644
--- a/docs/backup-restore-design.md
+++ b/docs/backup-restore-design.md
@@ -85,7 +85,7 @@ baron-sso-backup-YYYYMMDD-HHMMSSZ/
| 서비스 필터 | 주요 dump 산출물 | 포함 데이터 | 복구 중요도 | 복구 영향도 |
| --- | --- | --- | --- | --- |
-| `postgres` | `postgres/baron.dump` | Baron users, tenants, membership, user_login_ids, user_groups, RP metadata, API keys, WORKS mapping/outbox, Keto outbox, consent projection 등 | 필수 | Baron control plane의 원장이다. 누락되면 사용자/테넌트/RP/WORKS 참조가 끊기고 Ory DB만 복구해도 서비스 의미가 깨진다. |
+| `postgres` | `postgres/baron.dump` | Baron users, tenants, membership, user_login_ids, user_groups, RP metadata, API keys, WORKS mapping/outbox, Keto outbox, consent read model 등 | 필수 | Ory에 저장되지 않거나 조회가 불가능한 Baron control plane 데이터와 처리 상태를 담는다. 누락되면 사용자/테넌트/RP/WORKS 참조가 끊기고 Ory DB만 복구해도 서비스 의미가 깨진다. |
| `ory-postgres` | `postgres/globals.sql`, `postgres/ory_kratos.dump`, `postgres/ory_hydra.dump`, `postgres/ory_keto.dump` | Kratos identity/credential/session, Hydra client/consent/token state, Keto relation tuple | 필수 | 인증 주체, OAuth2/OIDC 상태, ReBAC 권한 원장이다. Baron DB와 시점이 다르면 로그인/인가/consent 불일치가 발생한다. |
| `clickhouse` | `clickhouse/baron_clickhouse/schema/*.sql`, `clickhouse/baron_clickhouse/data/*.native` | Baron audit_logs, RP usage event/aggregate 등 | 운영 정책상 필수 | 인증 자체를 막지는 않지만 감사 추적, 사용량 집계, 사고 분석 이력이 손실된다. |
| `ory-clickhouse` | `clickhouse/ory_clickhouse/schema/*.sql`, `clickhouse/ory_clickhouse/data/*.native` | Oathkeeper/Ory/Vector 접근 로그 | 운영 정책상 필수 | Ory edge 접근 로그와 장애 분석 근거가 손실된다. 인증 원장은 Postgres에 있으므로 직접 로그인 기능 영향은 제한적이다. |
diff --git a/docs/custom-field-jsonb-index-policy.md b/docs/custom-field-jsonb-index-policy.md
index 32610ed4..ae2f7a0c 100644
--- a/docs/custom-field-jsonb-index-policy.md
+++ b/docs/custom-field-jsonb-index-policy.md
@@ -3,10 +3,10 @@
## 현재 구조
- Tenant custom schema는 `tenants.config.userSchema` JSONB에 저장한다.
-- Tenant custom value는 backend DB의 `users.metadata` JSONB에 저장한다.
+- Tenant custom value는 Ory에 저장되지 않거나 Ory API로 필요한 조회가 불가능한 값에 한해 backend DB의 `users.metadata` JSONB read model에 저장한다.
- `isLoginId=true`인 Tenant field 값은 로그인 식별자 처리를 위해 `user_login_ids`에도 동기화한다.
- Ory Kratos traits에는 인증/식별에 필요한 최소 값만 동기화하는 방향으로 정리한다.
-- RP custom value는 backend DB의 `rp_user_metadata.metadata` JSONB에 별도 저장한다.
+- RP custom value는 Ory에 저장되지 않는 RP 범위 운영 값으로 보고 backend DB의 `rp_user_metadata.metadata` JSONB read model에 별도 저장한다.
## Tenant Custom Field
@@ -50,7 +50,7 @@ RP custom schema는 client metadata의 `customUserSchema`에 저장한다.
}
```
-RP custom value는 `rp_user_metadata` 테이블에 저장한다.
+RP custom value는 `rp_user_metadata` 테이블에 저장한다. 이 테이블은 Ory SSOT를 대체하지 않는 RP 범위 read model이며, Kratos traits나 token claim output을 원장으로 취급하지 않는다.
```text
client_id text
@@ -90,7 +90,7 @@ PUT payload:
- GIN 인덱스는 backend index manager가 별도 상태로 관리하는 방향을 원칙으로 한다.
- API 요청 처리 중 `CREATE INDEX`를 동기 실행하지 않는다.
-## Claim Projection
+## Claim Assembly
JWT 또는 userinfo 응답에서는 custom field를 top-level에 풀지 않는다.
Tenant/RP 단위로 묶어서 전달한다.
@@ -120,9 +120,9 @@ Tenant/RP 단위로 묶어서 전달한다.
- `claimEnabled=true` field만 RP claim 후보로 포함한다.
- 긴 JSON 값은 기본적으로 token claim보다 userinfo/profile API 응답에 싣는 방향을 우선한다.
-## 한맥가족 Tenant Claim Projection
+## 한맥가족 Tenant Claim Assembly
-한맥가족(`hanmac-family`) subtree의 tenant claim은 기본 claim과 상세 claim으로 나눈다. 기본 claim은 대표소속 tenant UUID인 `tenant_id`와 전체 소속 목록인 `joined_tenants`이며, RP가 `tenant` claim을 요청하면 tenant별 map 안에 조직 소속 정보를 묶어서 전달한다. 이 정보는 RP가 tenant context를 표시하거나 조직별 기본값을 선택하기 위한 projection이며, 관계형 데이터의 SoT는 PostgreSQL Business DB와 사용자 metadata이다.
+한맥가족(`hanmac-family`) subtree의 tenant claim은 기본 claim과 상세 claim으로 나눈다. 기본 claim은 대표소속 tenant UUID인 `tenant_id`와 전체 소속 목록인 `joined_tenants`이며, RP가 `tenant` claim을 요청하면 tenant별 map 안에 조직 소속 정보를 묶어서 전달한다. 이 정보는 RP가 tenant context를 표시하거나 조직별 기본값을 선택하기 위한 claim assembly 결과다. 관계/권한 판단은 Ory Keto를 기준으로 하고, Ory에 저장되지 않거나 조회가 불가능한 표시/검색 metadata만 Backend read model에서 보강한다.
기본 claim 예시는 다음과 같다.
@@ -235,7 +235,7 @@ Issue #775 구현 결과 기준으로 RP가 `tenant` claim을 요청했을 때
- 대표소속 결정은 명시적 `tenant_id`, `additionalAppointments`의 `representative/isPrimary/primary=true`, 가장 먼저 등록된 소속 순서로 적용한다.
- 생성 시 소속 tenant가 하나도 없으면 PERSONAL tenant를 자동 생성하고, 해당 tenant를 `tenant_id`와 `joined_tenants`에 포함한다.
- RP/client tenant context는 대표소속 `tenant_id`를 덮어쓰지 않는다.
-- tenant별 namespaced traits map이 없어도 `tenant_id` 또는 `additionalAppointments[].tenantId`를 기준으로 projection 항목을 만들 수 있다.
+- tenant별 namespaced traits map이 없어도 `tenant_id` 또는 `additionalAppointments[].tenantId`를 기준으로 claim assembly 항목을 만들 수 있다.
- 멀티 소속이면 기본 claim의 `joined_tenants`에 모든 소속 tenant를 넣는다. `tenant` claim 요청 시에는 `tenants`에도 모든 소속 tenant 상세를 넣고, `lead_tenants`에는 lead tenant만 넣는다.
- token 크기 보호를 위해 전체 조직도나 긴 custom JSON은 claim에 싣지 않고 profile/userinfo API 또는 backend API 응답으로 분리한다.
- RP는 `joined_tenants`로 전체 소속을 읽고, `lead_tenants`로 lead tenant를 빠르게 식별한다. 상세 표시는 `tenants[tenant_id]` 또는 `tenants[joined_tenants[n]]`와 `ancestors`를 조합한다.
diff --git a/docs/data-integrity-management.md b/docs/data-integrity-management.md
index b0877611..45c12875 100644
--- a/docs/data-integrity-management.md
+++ b/docs/data-integrity-management.md
@@ -51,7 +51,7 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이
- `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다.
- `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다.
- 개요 화면의 전체 테넌트 수는 `fetchAllTenants()`로 실제 cursor pagination을 끝까지 수집한 리스트 수를 우선 사용합니다. 이로써 super가 보는 전체 테넌트 수와 리스트 기반 수치가 같은 소스에서 나오도록 맞춥니다.
-- 개요 화면은 `super_admin`에게 전체 사용자 수(`totalUsers`)도 표시합니다. 이 값은 Kratos user projection 상태의 `projectedUsers` 기준입니다.
+- 개요 화면은 `super_admin`에게 전체 사용자 수(`totalUsers`)도 표시합니다. 이 값은 Kratos identity mirror 상태의 observed user count 기준입니다.
## 운영 주의
diff --git a/docs/identity-redis-mirror-policy-2026-06-09.md b/docs/identity-redis-mirror-policy-2026-06-09.md
index 52155409..e3177d67 100644
--- a/docs/identity-redis-mirror-policy-2026-06-09.md
+++ b/docs/identity-redis-mirror-policy-2026-06-09.md
@@ -4,9 +4,11 @@
## 결정
-사용자 identity에 대해 PostgreSQL DB projection을 SSOT 일치 보장 대상으로 취급하지 않습니다.
+사용자 identity에 대해 Backend DB 복제본이나 claim output을 SSOT 일치 보장 대상으로 취급하지 않습니다.
-Baron SSO의 identity 원장은 Ory Kratos입니다. Redis는 Kratos identity를 빠르게 조회하기 위한 mirror/cache 계층이고, PostgreSQL `users`는 Baron 비즈니스 메타데이터, WORKS/Keto/RP 연동 참조, 감사 가능한 로컬 레코드로만 사용합니다.
+Baron SSO의 identity 원장은 Ory Kratos입니다. Redis는 Kratos identity를 빠르게 조회하기 위한 mirror/cache 계층이고, Backend DB는 Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 데이터의 read model로만 사용합니다.
+
+Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend나 외부 API가 직접 소비하지 않습니다. Backend가 Redis와 허용된 read model을 조합해 cursor 기반 API로 제공합니다.
## 역할 분리
@@ -14,13 +16,13 @@ Baron SSO의 identity 원장은 Ory Kratos입니다. Redis는 Kratos identity를
| --- | --- | --- |
| Kratos `identities` | identity SSOT | 인증 주체, credentials, recovery/verifiable address의 원장 |
| Redis identity mirror | cache/read mirror | 빠른 목록/검색/단건 조회. stale 가능 |
-| PostgreSQL `users` | business local record | tenant, WORKS, RP, Keto 연동에 필요한 Baron 로컬 상태 |
+| Backend DB read model | Ory 보완 read model | Ory에 저장되지 않거나 조회 불가능한 업무/운영 데이터 |
-PostgreSQL `users`의 visible count를 Kratos identity total로 표시하지 않습니다. 화면과 API에서는 identity mirror count와 local business user count를 분리해서 보여야 합니다.
+Backend DB read model의 visible count를 Kratos identity total로 표시하지 않습니다. 화면과 API에서는 identity mirror count와 허용된 read model count를 분리해서 보여야 합니다.
## 현재 필드 대조
-현재 코드 기준으로 Kratos traits와 backend DB `users`는 일부 필드를 중복 보관합니다. Redis mirror 전환 이후에는 Kratos traits를 인증/기본 identity 필드 중심으로 줄이고, Baron 업무/조직/연동 정보는 backend DB 전용으로 이동하는 방향을 기준으로 합니다.
+현재 코드 기준으로 Kratos traits와 backend DB `users`는 일부 필드를 중복 보관합니다. Redis mirror 전환 이후에는 Kratos traits를 인증/기본 identity 필드 중심으로 줄이고, Baron 업무/조직/연동 정보는 Ory에 저장되지 않거나 조회가 불가능한 경우에만 Backend read model로 유지합니다.
### Kratos에 유지할 identity 필드
@@ -38,32 +40,32 @@ PostgreSQL `users`의 visible count를 Kratos identity total로 표시하지 않
| 필드 | 현재 코드 사용 | 전환 방향 |
| --- | --- | --- |
-| `tenant_id` | 대표 테넌트, profile, local user sync | backend `users.tenant_id`와 membership/Keto 기준으로 이동. Kratos에는 최소 claim 캐시로만 허용 |
+| `tenant_id` | 대표 테넌트, profile, local user sync | Keto relation과 Backend read model 기준으로 이동. Kratos에는 identity 원장 필드로 추가하지 않음 |
| `department` | 사용자 표시, 조직도, WORKS 비교 | backend `users.department` 또는 tenant membership metadata 기준 |
| `grade` | 직급 표시, role fallback legacy | backend `users.grade`. role fallback 용도 제거 |
| `position` | 직책 표시 | backend `users.position` |
| `jobTitle` | 직무 표시 | backend `users.job_title` |
| `affiliationType` | 내부/외부/게스트 구분 | backend `users.affiliation_type` |
| `relying_party_id` | RP admin profile 보조 | backend/RP relation 기준 |
-| `additionalAppointments` | 다중 소속 표시/WORKS 연동 | backend membership metadata 또는 `users.metadata` 기준 |
+| `additionalAppointments` | 다중 소속 표시/WORKS 연동 | Keto relation과 Backend read model 기준 |
| `sub_email`, `aliasEmails`, `secondary_emails`, `worksmobileAliasEmails` | WORKS alias 및 보조 이메일 | backend `users.metadata` 또는 명시 테이블 기준 |
| tenant UUID namespaced metadata | tenant별 custom schema 값 | backend `users.metadata` 또는 전용 custom-field storage 기준 |
-### backend DB에만 저장되거나 DB가 원장이어야 하는 정보
+### Backend read model로만 허용하는 정보
-| 데이터 | 저장 위치 | Kratos에 두지 않는 이유 |
+| 데이터 | 저장 위치 | Ory SSOT와의 관계 |
| --- | --- | --- |
-| soft-delete 상태 | `users.deleted_at` | Baron 운영/감사 로컬 상태. Kratos identity 삭제/비활성화와 의미가 다름 |
-| Baron 사용자 상태 세부값 | `users.status` | WORKS provision/deprovision, org visible 정책과 결합된 업무 상태 |
-| WORKS mapping/outbox/job 상태 | `worksmobile_*` 테이블 | 외부 SaaS 연동 상태이며 identity 인증 원장이 아님 |
-| Keto outbox 및 relation sync 상태 | `keto_outboxes`, Keto | 권한/관계 원장과 처리 상태 |
-| RP metadata/consent/usage | `rp_user_metadata`, `client_consents`, usage tables | RP별 업무 데이터와 위임 상태 |
-| tenant tree, slug, visibility, owner/admin | `tenants`, relation/outbox | 조직/권한 원장. Kratos traits에 넣으면 stale claim이 됨 |
-| custom field schema 및 tenant별 값 | tenant config, `users.metadata`, related tables | 조회/검색/검증 정책이 tenant별로 달라짐 |
-| `user_login_ids` row metadata | `user_login_ids` | Kratos는 identifier 값만 필요. 발급 tenant/field key는 backend 업무 정보 |
-| audit/session activity projection | audit/clickhouse/local tables | 감사/운영 분석 데이터 |
+| soft-delete 상태 | `users.deleted_at` | Ory Kratos identity 삭제/비활성화와 의미가 다른 Baron 운영 상태 |
+| Baron 사용자 상태 세부값 | `users.status` | WORKS provision/deprovision, org visible 정책과 결합된 운영 데이터 |
+| WORKS mapping/outbox/job 상태 | `worksmobile_*` 테이블 | 외부 SaaS 연동 상태이며 identity 원장이 아님 |
+| Keto outbox 및 relation sync 상태 | `keto_outboxes`, Keto | 권한/관계 원장은 Keto이고 DB는 처리 상태 read model |
+| RP metadata/consent/usage | `rp_user_metadata`, `client_consents`, usage tables | Ory에 저장되지 않거나 client 단위 조회가 불가능한 RP 업무 데이터 |
+| tenant tree 표시/검색 metadata | `tenants`, relation/outbox | 관계 판단은 Keto, 표시/검색/slug 조회는 Backend read model |
+| custom field schema 및 tenant별 값 | tenant config, `users.metadata`, related tables | Ory에 schema/검색 정책을 저장하거나 조회할 수 없는 tenant별 운영 데이터 |
+| `user_login_ids` row metadata | `user_login_ids` | Kratos는 identifier 값 원장, 발급 tenant/field key는 Backend 검증용 read model |
+| audit/session activity read model | audit/clickhouse/local tables | 감사/운영 분석 데이터 |
-정리하면, Kratos에는 “로그인과 subject 확인에 필요한 최소 identity”만 남기고, 조직도/WORKS/RP/Keto/감사/tenant custom schema에 필요한 데이터는 backend DB가 맡습니다.
+정리하면, Kratos에는 “로그인과 subject 확인에 필요한 최소 identity”만 남깁니다. 조직도/WORKS/RP/Keto/감사/tenant custom schema에 필요한 데이터도 Ory에 저장되거나 조회 가능한 경우에는 Ory를 기준으로 하고, 그렇지 않은 영역만 Backend read model을 허용합니다.
## 일관성 모델
@@ -87,6 +89,7 @@ Kratos Admin API를 backend 밖에서 직접 수정하는 경로는 운영 정
| 파일 | 역할 | 판정 |
| --- | --- | --- |
+| `backend/internal/service/identity_write_service.go` | Kratos identity 변경의 중앙 write boundary. 성공/실패 후 Redis mirror 상태를 갱신 또는 stale 표시 | 허용. 신규 identity write는 이 서비스를 거쳐야 함 |
| `backend/internal/service/kratos_admin_service.go` | Kratos Admin API list/get/create/update/delete/password/session client | 허용. 이후 `IdentityWriteService`의 하위 client로만 사용 |
| `backend/internal/service/ory_service.go` | legacy IDP provider. create/password/verifiable address 변경 시 Kratos Admin API 호출 | 허용하되 write-through 책임은 상위 `IdentityWriteService`로 이동 |
@@ -100,7 +103,7 @@ Kratos Admin API를 backend 밖에서 직접 수정하는 경로는 운영 정
| admin 사용자 bulk 생성 | `backend/internal/handler/user_handler.go` | `OryProvider.CreateUser` | 허용. 부분 성공/실패별 mirror 갱신 필요 |
| admin 사용자 수정 | `backend/internal/handler/user_handler.go` | `KratosAdmin.UpdateIdentity`, 선택적 `OryProvider.UpdateUserPassword` | 허용. password 변경도 identity write audit에 포함 |
| admin 사용자 삭제/bulk 삭제 | `backend/internal/handler/user_handler.go` | `KratosAdmin.DeleteIdentity` | 허용. Redis mirror delete 또는 tombstone 갱신 필요 |
-| 일반 회원가입 | `backend/internal/handler/auth_handler.go` | `IdpProvider.CreateUser` | 허용이지만 local DB sync가 goroutine 기반이라 write-through 기준에서는 약함 |
+| 일반 회원가입 | `backend/internal/handler/auth_handler.go` | `IdpProvider.CreateUser` | 허용이지만 Backend read model sync가 goroutine 기반이라 write-through 기준에서는 약함 |
| 내 프로필 수정 | `backend/internal/handler/auth_handler.go` | `KratosAdmin.UpdateIdentity` | 직접 `PUT /admin/identities/{id}` 호출 제거 완료. 향후 `IdentityWriteService` write-through 대상 |
| 비밀번호 재설정/내 비밀번호 변경 | `backend/internal/handler/auth_handler.go` | `IdpProvider.UpdateUserPassword` | 허용. traits mirror와 별도 audit event 필요 |
| 조직 그룹 멤버 추가 | `backend/internal/service/user_group_service.go` | Kratos write 없음 | Kratos `tenant_id`, `department` write 제거 완료. 조직/부서 정보는 backend DB/Keto/WORKS 기준 |
@@ -112,6 +115,8 @@ Kratos Admin API를 backend 밖에서 직접 수정하는 경로는 운영 정
| super-admin 보장 CLI | `backend/cmd/adminctl/main.go`, `backend/internal/bootstrap/admin_account.go` | `CreateUser`, `UpdateIdentityPassword` | 운영 bootstrap/정비 경로. 실행 후 Redis mirror 갱신 또는 refresh 필수 |
| 초기 admin seed | `backend/internal/bootstrap/kratos_seed.go` | `IdpProvider.CreateUser` | startup bootstrap 경로. 신규 환경에서만 허용하고 반복 실행 영향 점검 필요 |
| role 보정 CLI | `backend/cmd/fix_kratos_roles.go` | `ListIdentities` 후 `UpdateIdentity` | 기본 dry-run. 실제 변경은 `--dry-run=false --maintenance-window --mark-mirror-stale` 없이는 거부 |
+| WORKS 기준 Baron 보정 CLI | `backend/cmd/adminctl/worksmobile_sync.go` | `IdentityWriteService.UpdateIdentity` | 중앙 write boundary 강제. 변경 후 Redis mirror stale 표시 |
+| RP custom claim traits sync | `backend/internal/handler/dev_handler.go` | `IdentityWriteService.UpdateIdentity` | 중앙 write boundary 강제. RP read model과 Kratos traits 동기화 잔여 경로는 Ory SSOT 전환 대상 |
### backend와 Kratos Admin API를 모두 우회하는 경로
@@ -127,7 +132,7 @@ Kratos Admin API를 backend 밖에서 직접 수정하는 경로는 운영 정
- Kratos identity write는 `IdentityWriteService` 하나로 모으고, 성공한 create/update/delete/password 변경이 audit와 Redis mirror write-through를 남기게 합니다.
- `auth_handler.updateKratosIdentity`처럼 `KRATOS_ADMIN_URL`을 직접 읽어 `admin/identities`를 호출하는 코드는 금지합니다.
- `backend/cmd/fix_kratos_roles.go`와 Kratos DB 직접 UPDATE 스크립트는 `--dry-run`, `--maintenance-window`, `--mark-mirror-stale` 같은 명시적 가드 없이는 실행하지 못하게 합니다.
-- shell/SQL로 Kratos DB를 직접 수정한 경우에는 PostgreSQL projection이나 Redis mirror를 신뢰하지 않고, Kratos full refresh와 drift report를 먼저 실행합니다.
+- shell/SQL로 Kratos DB를 직접 수정한 경우에는 Backend read model이나 Redis mirror를 신뢰하지 않고, Kratos full refresh와 drift report를 먼저 실행합니다.
- CI에 정적 정책 테스트를 추가해 `admin/identities` write 호출과 `UPDATE identities` SQL이 허용 파일 밖에 생기면 실패시킵니다.
## Redis 키 설계
@@ -155,7 +160,7 @@ Kratos Admin API를 backend 밖에서 직접 수정하는 경로는 운영 정
- `identityTotal`: Redis mirror 기준 Kratos identity 수
- `localUserTotal`: PostgreSQL `users` 기준 Baron 로컬 사용자 수
- `mirrorStatus`: Redis mirror 상태
-- `items`: identity mirror와 local business metadata를 조합한 응답
+- `items`: identity mirror와 허용된 Backend read model을 조합한 응답
Redis cache miss 발생 시:
@@ -163,11 +168,11 @@ Redis cache miss 발생 시:
2. fallback 성공 시 Redis mirror를 갱신합니다.
3. fallback 실패 시 SSOT 조회 실패로 응답합니다.
-목록 조회는 Redis mirror가 `ready`가 아니면 경고 상태를 포함해야 합니다. DB projection을 대체 SSOT처럼 사용하지 않습니다.
+목록 조회는 Redis mirror가 `ready`가 아니면 경고 상태를 포함해야 합니다. Backend read model을 대체 SSOT처럼 사용하지 않습니다.
## Front 전송과 cursor 보장
-front로 전달되는 사용자 목록은 cursor 기반을 원칙으로 합니다. offset은 하위 호환 파라미터로만 유지하고, 신규 화면 또는 대량 조회 화면은 cursor 외 방식을 사용하지 않습니다.
+front/API로 전달되는 사용자 목록은 Backend가 제공하는 cursor 기반을 원칙으로 합니다. offset은 하위 호환 파라미터로만 유지하고, 신규 화면 또는 대량 조회 화면은 cursor 외 방식을 사용하지 않습니다.
### API 계약
@@ -206,8 +211,8 @@ front로 전달되는 사용자 목록은 cursor 기반을 원칙으로 합니
| `adminfront` 사용자 목록 | `useInfiniteQuery`로 `nextCursor` 사용 | 유지 |
| `adminfront` 일부 tenant/user group modal | `fetchUsers(20/100/1000, 0)` 단일 호출 | cursor helper로 전환 |
| `adminfront` bulk upload modal | `fetchUsers(10000, 0)` 단일 호출 | 금지. cursor 수집 helper 또는 서버 검증 API로 전환 |
-| `orgfront` 조직도 | `fetchUsers(5000, 0)` 단일 호출 | cursor 기반 전체 수집 helper로 전환 |
-| `orgfront` 조직 picker | `fetchUsers(5000, 0)` 단일 호출 | cursor 기반 전체 수집 helper로 전환 |
+| `orgfront` 조직도 | Redis orgchart snapshot 기반 | 유지. Backend가 Ory/Redis/read model을 조합해 제공 |
+| `orgfront` 조직 picker | Redis orgchart snapshot 기반으로 전환 | 유지. 인증 picker는 `fetchOrgChartSnapshot`, public picker는 token 기반 orgchart API 사용 |
| `orgfront/src/lib/adminApi.ts` | `UserListResponse`에 `nextCursor` 없음 | 타입 계약 보완 |
공통 helper 원칙:
@@ -232,14 +237,15 @@ refresh 중 불일치 또는 실패가 발생하면:
## 금지 사항
- Kratos partial list를 full snapshot으로 간주하지 않습니다.
-- PostgreSQL `users`를 Kratos identity total의 원장으로 사용하지 않습니다.
+- Backend read model을 Kratos identity total의 원장으로 사용하지 않습니다.
- Redis mirror refresh 실패를 숨기고 `ready`로 표시하지 않습니다.
- 외부 도구가 Kratos Admin API를 직접 수정하도록 허용하지 않습니다.
## 전환 작업
-1. `user_projection` 명칭과 API를 `identity_mirror` 성격으로 분리합니다.
+1. legacy user sync 명칭과 API를 `identity_mirror` 성격으로 분리합니다.
2. Redis repository에 Set/Sorted Set 또는 scan 가능한 index 연산을 추가합니다.
3. Kratos create/update/delete 성공 직후 Redis write-through 테스트를 추가합니다.
4. admin 사용자 목록 응답에서 identity count와 local user count를 분리합니다.
-5. 기존 DB projection 기반 count를 사용하는 화면과 WORKS 비교 경로를 점검합니다.
+5. 기존 Backend DB count를 identity count처럼 사용하는 화면과 WORKS 비교 경로를 점검합니다.
+6. Kratos identity 변경은 `IdentityWriteService` 경유를 강제하고, 직접 `KratosAdmin.UpdateIdentity` 경로를 정책 테스트로 차단합니다.
diff --git a/docs/integrations-org-context-json-api.md b/docs/integrations-org-context-json-api.md
index 3b049939..6fffbd18 100644
--- a/docs/integrations-org-context-json-api.md
+++ b/docs/integrations-org-context-json-api.md
@@ -2,7 +2,7 @@
## 목적
-외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성은 Baron SSO backend의 tenant/user projection을 SSOT로 사용하며, iframe 또는 `postMessage` 계약은 사용하지 않는다.
+외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성은 Ory SSOT에서 웜업한 Redis cache와 Ory에 저장되지 않거나 조회가 불가능한 Backend read model을 Backend가 조합해 cursor 기반 API로 제공한다. Backend DB나 claim output을 SSOT로 사용하지 않으며, iframe 또는 `postMessage` 계약은 사용하지 않는다.
## 인증
diff --git a/docs/kratos-user-traits-field-inventory.md b/docs/kratos-user-traits-field-inventory.md
index 7affe475..7db6c7e4 100644
--- a/docs/kratos-user-traits-field-inventory.md
+++ b/docs/kratos-user-traits-field-inventory.md
@@ -59,7 +59,7 @@
schema 추가 검토 후보:
-- backend projection에서 읽는 `position`, `jobTitle`
+- Backend read model에서 읽는 `position`, `jobTitle`
- 한맥가족 다중 소속을 metadata로 유지할 경우 `additionalAppointments`
- 대표 테넌트 표시값을 traits로 계속 줄 경우 `primaryTenantId`, `primaryTenantSlug`, `primaryTenantName`, `primaryTenantIsOwner`
@@ -78,5 +78,5 @@ schema 추가 검토 후보:
1. Personal 사용자는 사용자별 Personal 테넌트를 생성하지 않고 전역 `personal` 테넌트만 사용합니다.
2. Kratos traits는 인증/클레임에 필요한 최소 필드만 유지합니다.
-3. 조직도나 연동 전용 확장 데이터는 traits 최상위에 흩뿌리지 않고 Baron DB의 user projection 또는 명시된 metadata 구조로 모읍니다.
+3. 조직도나 연동 전용 확장 데이터는 traits 최상위에 흩뿌리지 않고 Ory에 저장되지 않거나 조회가 불가능한 Backend read model 또는 명시된 metadata 구조로 모읍니다.
4. `additionalProperties: true`를 바로 `false`로 바꾸면 기존 identity 갱신이 실패할 수 있으므로, 먼저 backend sanitizer와 마이그레이션으로 제거 후보를 정리한 뒤 schema를 닫습니다.
diff --git a/docs/rp-iam-integration-guide.md b/docs/rp-iam-integration-guide.md
index 4185717a..6c712e25 100644
--- a/docs/rp-iam-integration-guide.md
+++ b/docs/rp-iam-integration-guide.md
@@ -157,7 +157,7 @@ Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을
주의사항:
-- Tenant tree, 직급, 직무, 직책은 PostgreSQL Business SoT와 tenant/user metadata를 기준으로 합니다. Kratos traits는 인증 식별 정보 중심으로 유지해야 하며, 관계형 데이터의 영구 SoT로 취급하지 않습니다.
+- Tenant tree, 직급, 직무, 직책은 Ory Keto 관계와 Backend read model을 조합해 제공합니다. Kratos traits는 인증 식별 정보 중심으로 유지해야 하며, Backend DB metadata나 token claim output도 관계형 데이터의 영구 SSOT로 취급하지 않습니다.
- Token 크기가 커질 수 있으므로 RP가 긴 조직 전체 정보를 필요로 하면 ID token claim보다 userinfo/profile API 또는 Baron backend API 연동을 우선 검토합니다.
- RP는 `lead_tenants` 또는 `tenants.*.lead`만으로 보안상 중요한 권한을 단독 판정하지 않습니다. 권한 변경/민감 리소스 접근은 Keto 기반 Baron authorization contract를 함께 사용해야 합니다.
diff --git a/docs/tenant-policy.md b/docs/tenant-policy.md
index 4ce7147e..7b39c9a8 100644
--- a/docs/tenant-policy.md
+++ b/docs/tenant-policy.md
@@ -11,12 +11,13 @@
- **`COMPANY_GROUP`**: B2B2B 지주사/그룹사. 여러 `COMPANY`를 하위로 거느리며 권한을 통합합니다.
- **`USER_GROUP`**: 사내 조직 (본부/팀 등). 과거에는 분리된 개념이었으나, 현재는 완벽한 통합을 위해 테넌트의 한 종류로 1:1 매핑됩니다.
-## 2. 외부 백엔드 데이터베이스 의무 채택 (Separation of SoT)
+## 2. Ory SSOT와 Backend read model 분리
-Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터를 저장하는 것은 토큰 비대화 및 쿼리 성능 저하를 일으키는 안티 패턴입니다. 따라서 데이터의 진실 공급원(SoT)을 철저히 분리합니다.
+Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터를 저장하는 것은 토큰 비대화 및 쿼리 성능 저하를 일으키는 안티 패턴입니다. 하지만 Backend DB를 별도 원장으로 세우지도 않습니다. 권한/관계 판단은 Ory Keto, identity 판단은 Ory Kratos를 기준으로 하고, Backend DB는 Ory에 저장되지 않거나 Ory API로 필요한 조회가 불가능한 조직 표시/검색 데이터의 read model만 보관합니다.
-- **Ory Kratos (Identity SoT)**: 이메일, 패스워드 등 순수 식별 정보만 저장합니다.
-- **PostgreSQL (Business SoT)**: 반드시 커스텀 외부 백엔드 DB를 구축하여, 테넌트의 트리 구조, 사용자 직급, 애플리케이션 설정 등을 전담하여 관리합니다.
+- **Ory Kratos (Identity SSOT)**: 이메일, 패스워드 등 인증 식별 정보를 저장합니다.
+- **Ory Keto (Relationship SSOT)**: 테넌트 소속, 소유, 접근 같은 권한 관계를 저장하고 판정합니다.
+- **Backend DB read model**: Ory에 저장되지 않거나 조회가 불가능한 테넌트 표시/검색 metadata, 설정, 외부 연동 상태만 저장합니다.
## 3. 데이터베이스 스키마 분리 전략
diff --git a/docs/tenant-usergroup-policy.md b/docs/tenant-usergroup-policy.md
index f5688733..5175a103 100644
--- a/docs/tenant-usergroup-policy.md
+++ b/docs/tenant-usergroup-policy.md
@@ -22,10 +22,10 @@
- 시스템의 모든 자원(예: RelyingParty, 앱)은 반드시 특정 `Tenant`가 소유(`manage`)합니다.
- 그러나 자원의 소유권과 **누가 접근할 수 있는가(가시성, `access`)는 별개**입니다. 내부망용 앱(Private)과 대국민 서비스(Public)를 동일한 기업(Tenant)이 동시에 소유하고 제어할 수 있습니다.
-### 1.4 외부 백엔드 데이터베이스 아키텍처 의무 채택 (Separation of SoT)
+### 1.4 Ory SSOT와 Backend read model 분리
사용자 데이터를 Kratos의 내부 트레이트(Traits)에 무분별하게 저장하는 것은 안티 패턴입니다. 이는 토큰 비대화와 쿼리 성능 저하를 초래합니다.
- **Kratos (Identity):** "누구인가?" (인증, 이메일, 패스워드 등 순수 식별 정보). 테넌트, 직급 등 관계형 데이터는 절대 보관하지 않습니다.
-- **PostgreSQL (Business):** "어디에 속하며 조직 구조는 어떠한가?" (직급, 조직도, 테넌트 설정 등).
+- **Backend DB read model:** Ory에 저장되지 않거나 Ory API로 필요한 조회가 불가능한 조직 표시/검색 metadata, 테넌트 설정, 외부 연동 상태만 보관합니다.
- **Keto (ReBAC Authorization Backbone):** "무엇을 할 수 있는가?" (권한 및 상속).
---
@@ -140,9 +140,9 @@ graph TD
- 모든 테넌트(`COMPANY`, `PERSONAL`)는 소수의 공유된 Hydra 클러스터를 사용합니다.
- Hydra 클러스터 앞단에 도메인 및 헤더를 재작성하는 지능형 프록시(API Gateway)를 배치하여, 테넌트별로 물리적으로 분리된 것과 같은 라우팅 효과를 제공합니다.
-### 5.2 동적 클레임 주입 (Dynamic Claim Injection)
-- 로그인 및 동의(Consent) 흐름은 전적으로 외부 백엔드 데이터베이스(Business SoT)가 주도합니다.
-- 백엔드는 요청된 클라이언트(RP)의 테넌트 맥락(Context)을 파악하고, 유저가 속한 현재 조직 정보 및 권한(Role)을 Hydra에 전달하여 **ID Token의 Custom Claim으로 동적 주입**합니다.
+### 5.2 동적 클레임 조립 (Dynamic Claim Assembly)
+- 로그인 및 동의(Consent) 흐름의 프로토콜 원장은 Ory Hydra입니다.
+- 백엔드는 Ory에서 확인한 identity/relationship과 허용된 read model을 조합해 요청된 클라이언트(RP)의 테넌트 맥락(Context)을 계산하고, Hydra에 전달할 claim을 조립합니다.
---
@@ -160,4 +160,4 @@ Kratos 웹훅 통신 지연이나 이중 쓰기(Dual-Write) 오류로 인한 '
### 6.3 삭제 정책 (Cascade) 및 정기 대사
- **즉시 회수:** 백엔드 DB에서 Soft Delete(`deleted_at`)가 발생하면, Outbox 워커는 지연 없이 즉각적으로 Keto의 튜플을 Hard Delete 합니다.
-- **정기 대사 (Reconciliation):** Kratos(Identity), PostgreSQL(DB), Keto(ReBAC) 3자 간의 불일치(고아 튜플, 누락된 멤버십 등)를 매일 1회 이상 배치 크론 잡을 통해 능동적으로 색출하고 자동 복구/삭제합니다.
\ No newline at end of file
+- **정기 대사 (Reconciliation):** Kratos(Identity), PostgreSQL(DB), Keto(ReBAC) 3자 간의 불일치(고아 튜플, 누락된 멤버십 등)를 매일 1회 이상 배치 크론 잡을 통해 능동적으로 색출하고 자동 복구/삭제합니다.
diff --git a/docs/user-projection-visibility-audit-2026-06-08.md b/docs/user-projection-visibility-audit-2026-06-08.md
index 064c827b..87303e4d 100644
--- a/docs/user-projection-visibility-audit-2026-06-08.md
+++ b/docs/user-projection-visibility-audit-2026-06-08.md
@@ -1,14 +1,14 @@
-# 사용자 projection 가시성 감사 보고서
+# 사용자 가시성 감사 보고서
작성 시각: 2026-06-08 16:55 KST
관련 이슈:
- #1035: adminfront 사용자 레지스트리 total이 Kratos 250건 제한으로 잘못 표시됨
-- #1036: 사용자 projection 가시성 영향 범위 검증 및 WORKS 비교 표 row count 표시
+- #1036: 사용자 가시성 영향 범위 검증 및 WORKS 비교 표 row count 표시
## 결론
-`총 250명` 표시는 단순 UI 문제가 아니라, Kratos partial list를 full snapshot처럼 처리한 projection 동기화 버그였습니다.
+`총 250명` 표시는 단순 UI 문제가 아니라, Kratos partial list를 full snapshot처럼 처리한 legacy sync 버그였습니다.
현재 로컬 DB와 API는 다음 상태입니다.
@@ -16,7 +16,7 @@
| --- | ---: | --- |
| users 전체 | 2,114 | `deleted_at` 포함 전체 row |
| visible users | 1,917 | `deleted_at IS NULL`, adminfront/orgfront 사용자 목록 기준 |
-| soft-deleted users | 197 | 사용자 삭제 또는 과거 projection 문제로 숨겨진 row |
+| soft-deleted users | 197 | 사용자 삭제 또는 과거 legacy sync 문제로 숨겨진 row |
| CSV 원본 줄 수 | 1,887 | 헤더 포함 |
| CSV 실제 데이터 행 | 1,886 | 헤더 제외 |
| 이번 import 사용자 | 1,886 | 모두 DB 매칭, 모두 visible |
@@ -33,8 +33,8 @@
보고 파일 위치:
-- `reports/user-projection-visibility-audit-20260608-1645/existing_users_not_in_saman_import.csv`
-- `reports/user-projection-visibility-audit-20260608-1645/imported_users_missing_or_soft_deleted.csv`
+- `reports/user-visibility-audit-20260608-1645/existing_users_not_in_saman_import.csv`
+- `reports/user-visibility-audit-20260608-1645/imported_users_missing_or_soft_deleted.csv`
파일 내용:
@@ -100,9 +100,9 @@ soft-deleted 기존 사용자 197명과 WORKS comparison `baronId`를 대조한
이번 문제는 단일 UI 카운트 문제가 아니라 다음 경계가 한꺼번에 얽힌 문제였습니다.
-1. Kratos identity list는 partial list인데 backend projection sync가 full snapshot으로 처리했습니다.
-2. projection sync가 local DB soft-delete까지 수행해 사용자 가시성 자체를 손상시켰습니다.
-3. adminfront 사용자 목록, orgfront 조직도, WORKS comparison이 모두 사용자 projection을 다른 방식으로 소비하고 있었습니다.
+1. Kratos identity list는 partial list인데 legacy backend sync가 full snapshot으로 처리했습니다.
+2. legacy sync가 Backend DB soft-delete까지 수행해 사용자 가시성 자체를 손상시켰습니다.
+3. adminfront 사용자 목록, orgfront 조직도, WORKS comparison이 모두 사용자 데이터를 다른 방식으로 소비하고 있었습니다.
4. WORKS comparison은 사용자 목록이 아니라 Baron/WORKS 양쪽 차이를 보여주는 비교 화면이라 total 의미가 달랐습니다.
5. 운영자가 partial data인지 바로 볼 수 있도록 WORKS 표 row count가 필요했습니다.
@@ -110,14 +110,14 @@ soft-deleted 기존 사용자 197명과 WORKS comparison `baronId`를 대조한
현재 구조는 다음과 같습니다.
-- Ory/Kratos -> backend projection sync: 현재 `ListIdentities()` partial 조회입니다. offset/cursor 전체 순회가 아닙니다.
-- backend projection -> adminfront 사용자 목록: `cursor`가 있으면 cursor pagination, 없으면 offset pagination을 받습니다. adminfront는 infinite query로 `nextCursor`를 사용합니다.
-- backend projection -> orgfront 조직도/picker: 현재 `limit=5000&offset=0` 단일 offset 조회입니다.
+- Ory/Kratos -> Backend identity mirror warmup: `ListIdentities()` partial 조회를 전체 snapshot으로 취급하면 안 됩니다. 전체 수집은 pagination을 끝까지 따라가야 합니다.
+- Backend -> adminfront 사용자 목록: `cursor`가 있으면 cursor pagination, 없으면 offset pagination을 받습니다. adminfront는 infinite query로 `nextCursor`를 사용합니다.
+- Backend -> orgfront 조직도/picker: Redis orgchart snapshot 또는 Backend cursor API를 사용해야 하며 `limit=5000&offset=0` 단일 offset 조회는 금지합니다.
- WORKS comparison: backend가 비교 결과 배열을 만들어 내려주고, adminfront가 검색/필터 후 화면 row를 표시합니다.
## 재발 방지 조치
-- 사용자 목록 API는 Kratos가 아니라 local projection DB를 primary source로 사용합니다.
-- Kratos partial list에 없는 사용자를 projection sync에서 삭제하지 않도록 수정했습니다.
+- 사용자 목록 API는 Backend가 Ory-warmed Redis cache와 허용된 read model을 조합해 cursor로 제공합니다.
+- Kratos partial list에 없는 사용자를 legacy sync에서 삭제하지 않도록 수정했습니다.
- WORKS comparison에서 soft-deleted local user가 들어와도 comparison row로 노출되지 않도록 방어 테스트와 로직을 추가했습니다.
- WORKS comparison 표에 `표시 N / 전체 M` row count를 표시했습니다.
diff --git a/docs/wiki-ory-ssot-cache-policy-update-2026-06-10.md b/docs/wiki-ory-ssot-cache-policy-update-2026-06-10.md
new file mode 100644
index 00000000..5015c562
--- /dev/null
+++ b/docs/wiki-ory-ssot-cache-policy-update-2026-06-10.md
@@ -0,0 +1,134 @@
+# Wiki Ory SSOT 및 Redis Cache 정책 업데이트 초안
+
+작성일: 2026-06-10
+
+## 목적
+
+Wiki에 남아 있는 Backend DB 원장화 기준과 claim output 원장화 기준을 폐기하고 다음 정책으로 통일합니다.
+
+- 인증 identity 원장: Ory Kratos
+- 권한/관계 원장: Ory Keto
+- OAuth/OIDC 원장: Ory Hydra
+- Backend DB: Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 데이터의 read model
+- Redis: Ory 또는 허용된 read model의 성능 cache/mirror
+- Front/API 전송: Ory에서 Redis cache로 웜업된 데이터를 Backend가 cursor 기반 API로 제공
+
+## Wiki 검색 결과
+
+Gitea Wiki를 조회한 결과, 다음 페이지는 현재 정책과 충돌하는 문구가 있어 업데이트가 필요합니다.
+
+| Wiki page | 확인된 문제 | 권장 조치 |
+| --- | --- | --- |
+| `Data SoT Architecture Policy` | Backend DB 중심 admin list read path와 async write-behind를 기본 write path로 설명합니다. | 아래 대체 본문으로 교체합니다. |
+| `[Architecture] Kratos SoT Consolidation` | 관리 read-model의 원장을 Backend DB로 설명하고 Kratos 데이터를 DB에 복제한다고 설명합니다. | 아래 대체 본문으로 교체합니다. |
+| `tenant-policy.-` | Wiki page 조회 이름이 불안정해 직접 본문 확인은 실패했습니다. 로컬 `docs/tenant-policy.md`와 같은 내용이면 Backend DB 원장화 문구를 삭제해야 합니다. | `docs/tenant-policy.md` 변경본 기준으로 동기화합니다. |
+| `유저-그룹-및-테넌트-통합-권한-정책` | Wiki page 조회 이름이 불안정해 직접 본문 확인은 실패했습니다. 로컬 `docs/tenant-usergroup-policy.md`와 같은 내용이면 Backend DB 주도 consent 문구를 삭제해야 합니다. | `docs/tenant-usergroup-policy.md` 변경본 기준으로 동기화합니다. |
+
+## `Data SoT Architecture Policy` 대체 본문
+
+```md
+# Baron SSO Data SoT Architecture Policy
+
+## 1. Core Principle: Ory Stack is the Single Source of Truth
+
+Baron SSO에서 인증 identity, 권한 관계, OAuth/OIDC 위임의 원장은 Ory Stack입니다.
+
+- Identity/profile 인증 원장: Ory Kratos
+- Authorization/ReBAC 원장: Ory Keto
+- OAuth/OIDC client, consent, token state 원장: Ory Hydra
+
+Backend DB는 Ory를 대체하는 원장이 아닙니다. Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 업무 데이터의 read model, 감사 로그, 처리 상태, 성능 cache 보조 데이터만 허용합니다.
+
+Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로 front 또는 외부 API에 제공합니다. frontend는 Redis나 Backend DB 복제본을 원장처럼 직접 소비하지 않습니다.
+
+## 2. Component Policies
+
+### 2.1 Identity & User Profile
+
+- Ory Kratos identity가 subject, credentials, recovery/verification address, 인증 식별자의 원장입니다.
+- Kratos identity 변경은 Backend의 중앙 `IdentityWriteService`를 경유해야 합니다.
+- Redis identity mirror는 빠른 단건/목록/검색 조회를 위한 cache입니다. stale 가능성을 API 응답에 드러내야 합니다.
+- Backend DB `users`는 Ory에 저장되지 않거나 Ory에서 필요한 방식으로 조회할 수 없는 Baron 운영 데이터의 read model입니다.
+
+### 2.2 Permissions & Relationships
+
+- 권한 판단과 관계 tuple의 원장은 Ory Keto입니다.
+- Backend DB는 relation command outbox, 처리 상태, 조직 표시/검색에 필요한 read model을 보관할 수 있습니다.
+- 보안상 중요한 권한 판정은 Backend DB metadata나 token claim만으로 수행하지 않고 Keto check를 거쳐야 합니다.
+
+### 2.3 OAuth2 Clients & Sessions
+
+- OAuth2 client, consent, token state의 프로토콜 원장은 Ory Hydra입니다.
+- `client_consents` 같은 Backend read model은 Hydra가 제공하지 않는 조회 축을 보완하기 위한 모델입니다.
+- client secret 원문처럼 Hydra가 해시만 보관하는 값은 재발급/운영 목적의 별도 보관 정책과 감사 로그를 가져야 합니다.
+
+## 3. Data Flow & Synchronization Strategy
+
+### 3.1 Write Path
+
+1. 클라이언트 또는 운영 도구가 Backend API/CLI를 호출합니다.
+2. Backend가 중앙 service를 통해 Ory API를 동기 호출합니다.
+3. Ory write 성공 후 Ory ID로 재조회합니다.
+4. Redis mirror를 갱신하거나 갱신 실패 시 `stale`/`failed` 상태를 기록합니다.
+5. Ory에 저장되지 않거나 조회 불가능한 read model만 Backend DB에 갱신합니다.
+
+### 3.2 Read Path
+
+- Self context: Ory session/token 또는 Ory API를 기준으로 검증합니다.
+- Admin/list context: Backend가 Redis mirror와 허용된 read model을 조합해 cursor 기반 API로 제공합니다.
+- API response는 `identityTotal`, read model count, mirror status를 구분해야 합니다.
+
+### 3.3 Conflict Resolution
+
+불일치가 발견되면 Ory Stack의 데이터를 기준으로 Redis mirror와 Backend read model을 보정합니다. Backend read model이나 token claim assembly 결과를 Ory보다 우선하는 근거로 사용하지 않습니다.
+```
+
+## `[Architecture] Kratos SoT Consolidation` 대체 본문
+
+```md
+# [Architecture] Kratos SoT Consolidation & Redis Cache Strategy
+
+이 문서는 Kratos identity SSOT와 Redis cache 전략을 정의합니다.
+
+## 1. Identity Source
+
+- 원장: Ory Kratos identity
+- 중앙 write path: Backend `IdentityWriteService`
+- Redis: identity mirror/cache
+- Backend DB: Ory에 저장되지 않거나 Ory API로 필요한 조회가 불가능한 Baron 운영 데이터의 read model
+
+`role`, `tenant_id`, 조직 표시 metadata를 Kratos traits에 무제한 추가하거나 Backend DB를 별도 identity 원장으로 삼지 않습니다.
+
+## 2. Redis Cache Strategy
+
+Redis는 성능 cache입니다. Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로 front 또는 외부 API에 제공합니다.
+
+- `identity:mirror:{identityID}`: Kratos identity summary 단건 cache
+- `identity:index:*`: Backend cursor API용 identity 목록/검색 index
+- `identity:mirror:state`: mirror 상태, count, last error
+
+Cache miss가 발생한 단건 조회는 Kratos `GetIdentity`로 fallback하고, 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 API 응답에 경고 상태를 포함합니다.
+
+## 3. Write Path Guard
+
+Kratos identity 변경은 `IdentityWriteService` 경유를 강제합니다.
+
+- `backend/internal/handler/dev_handler.go`: RP custom claim 관련 잔여 Kratos traits sync도 중앙 service를 경유합니다.
+- `backend/cmd/adminctl/worksmobile_sync.go`: WORKS 기준 Baron 보정도 중앙 service를 경유합니다.
+- Kratos Admin API나 Kratos DB 직접 수정은 maintenance guard와 mirror stale 표시 없이 금지합니다.
+
+## 4. Read Path Guard
+
+Admin/list 화면과 조직도/picker는 Backend cursor API 또는 Redis orgchart snapshot API를 사용합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 신규 구현에서 금지합니다.
+
+## 5. Allowed Backend Data
+
+Backend DB에 허용되는 데이터는 다음 범위입니다.
+
+- Ory에 저장되지 않는 외부 연동 상태
+- Ory API로 필요한 조회 축이 제공되지 않는 운영 read model
+- 감사 로그와 처리 상태
+- token/userinfo claim assembly에 필요한 RP 범위 metadata
+
+이 데이터는 Ory SSOT를 대체하지 않습니다.
+```
diff --git a/docs/works-only-user-recovery-2026-06-09.md b/docs/works-only-user-recovery-2026-06-09.md
index 2930a8cc..194626b3 100644
--- a/docs/works-only-user-recovery-2026-06-09.md
+++ b/docs/works-only-user-recovery-2026-06-09.md
@@ -13,7 +13,7 @@
## 원인
-이전 사용자 projection 동기화 코드가 Kratos `ListIdentities()` 결과를 전체 identity 목록으로 간주했습니다. 해당 API 결과는 제한된 페이지 결과였고, 그 목록에 없던 기존 사용자가 Baron `users`에서 soft-delete 처리되었습니다.
+이전 사용자 legacy sync 코드가 Kratos `ListIdentities()` 결과를 전체 identity 목록으로 간주했습니다. 해당 API 결과는 제한된 페이지 결과였고, 그 목록에 없던 기존 사용자가 Baron `users`에서 soft-delete 처리되었습니다.
이로 인해 WORKS에는 사용자가 남아 있고 `externalKey`도 Baron 사용자 UUID를 가리키지만, Baron 비교 로직에서는 soft-deleted 사용자가 visible 사용자로 조회되지 않아 `missing_in_baron`으로 표시되었습니다.
@@ -101,8 +101,8 @@
이미 적용된 코드 변경으로 다음 조건을 방어합니다.
-- admin 사용자 목록은 Kratos 250개 제한 결과가 아니라 로컬 projection repository를 기준으로 조회합니다.
-- projection replace 동기화는 Kratos partial list에 없는 사용자를 삭제 처리하지 않습니다.
+- admin 사용자 목록은 Kratos 250개 제한 결과가 아니라 Backend cursor API와 identity mirror 상태를 기준으로 조회합니다.
+- legacy replace sync는 Kratos partial list에 없는 사용자를 삭제 처리하지 않습니다.
- WORKS 비교 로직은 soft-deleted Baron 사용자를 visible 사용자로 취급하지 않습니다.
- WORKS 비교 UI에는 필터링 후 표시 row와 전체 row 수를 함께 표시합니다.
diff --git a/docs/worksmobile-directory-sync-technical-review.md b/docs/worksmobile-directory-sync-technical-review.md
index a6947131..78feb742 100644
--- a/docs/worksmobile-directory-sync-technical-review.md
+++ b/docs/worksmobile-directory-sync-technical-review.md
@@ -10,7 +10,7 @@
## 현재 Baron SSO 구조 요약
-Baron SSO는 Ory Stack을 SoT로 두고, PostgreSQL은 read-model 및 비즈니스 메타데이터 저장소로 사용합니다. `docs/SoT_Architecture_Policy.md`와 `docs/tenant-usergroup-policy.md` 기준으로 Identity는 Kratos, 권한/멤버십은 Keto, 테넌트/조직 메타데이터는 PostgreSQL이 담당합니다.
+Baron SSO는 Ory Stack을 SSOT로 둡니다. `docs/SoT_Architecture_Policy.md`와 `docs/tenant-usergroup-policy.md` 기준으로 Identity는 Kratos, 권한/멤버십은 Keto가 원장입니다. PostgreSQL은 Ory에 저장되지 않거나 조회가 불가능한 Worksmobile/조직 표시/검색 데이터의 read model과 처리 상태 저장소로만 사용합니다.
현재 사용자 생성 흐름은 다음과 같습니다.
@@ -178,7 +178,7 @@ Baron Kratos identity를 Worksmobile user로 보냅니다.
초기 비밀번호는 Worksmobile user upsert outbox payload에 `loginEmail`, `initialPassword` 형태로 함께 보관하고, adminfront의 한맥가족 Worksmobile 관리 화면에서 `email,initialPassword,status,lastError` CSV로 다운로드할 수 있게 합니다. 생성 성공/실패 판정은 outbox 작업 상태(`processed`, `failed`)와 함께 확인할 수 있으며, 운영상 평문 초기 비밀번호가 포함되므로 다운로드 권한은 `hanmac-family` tenant manage 권한으로 제한하고 보존 기간 정책을 별도 확정해야 합니다.
-현재 backend `CreateUser`와 `UpdateUser`는 adminfront가 보내는 top-level `additionalAppointments` 및 `metadata.additionalAppointments`를 수용합니다. 한맥가족 단건 생성에서 대표 `tenantSlug` 없이 appointment만 오는 경우에는 first/primary appointment tenant를 대표 tenant로 해석해 Kratos traits, local read-model, Worksmobile enqueue가 누락되지 않게 합니다.
+현재 backend `CreateUser`와 `UpdateUser`는 adminfront가 보내는 top-level `additionalAppointments` 및 `metadata.additionalAppointments`를 수용합니다. 한맥가족 단건 생성에서 대표 `tenantSlug` 없이 appointment만 오는 경우에는 first/primary appointment tenant를 대표 tenant로 해석해 Ory/Keto 관계, 허용된 Backend read model, Worksmobile enqueue가 누락되지 않게 합니다.
### 구성원 수정과 비밀번호 정책
@@ -324,19 +324,19 @@ Worksmobile 운영 화면은 `orgfront`가 아니라 `adminfront`의 tenant deta
### 신규 사용자 단건 생성
-- 후보 위치: `UserHandler.CreateUser`에서 Kratos 생성, local DB sync, login ID sync, Keto outbox enqueue 후
+- 후보 위치: `UserHandler.CreateUser`에서 Kratos 생성, 허용된 Backend read model sync, login ID sync, Keto outbox enqueue 후
- payload에는 `identityID`, email, name, phone, tenantID, metadata/additionalAppointments를 포함합니다.
- `hanmac-family` scope가 아니면 enqueue하지 않습니다.
### 신규 사용자 bulk 생성
-- 후보 위치: `UserHandler.BulkCreateUsers`에서 row별 local DB sync와 Keto outbox enqueue 후
+- 후보 위치: `UserHandler.BulkCreateUsers`에서 row별 허용된 Backend read model sync와 Keto outbox enqueue 후
- row별 partial success를 유지하고, Worksmobile enqueue 실패는 사용자 생성 실패와 분리하는 것이 좋습니다.
- 단, enqueue 실패는 audit/error로 남기고 운영자가 재시도할 수 있어야 합니다.
### 사용자 수정/소속 변경
-- 후보 위치: `UserHandler.UpdateUser`에서 Kratos update와 local DB sync 후
+- 후보 위치: `UserHandler.UpdateUser`에서 중앙 `IdentityWriteService` 기반 Kratos update와 허용된 Backend read model sync 후
- `email`, `name`, `phone`, `companyCode`, `tenant_id`, `metadata.additionalAppointments` 변경 시 `USER UPSERT` enqueue
- `suspended`는 Worksmobile suspend로 동기화합니다.
- `temporary_leave`는 Worksmobile 계정을 유지합니다.
diff --git a/locales/en.toml b/locales/en.toml
index 58d38cc0..4fad3b26 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -1361,6 +1361,7 @@ add_existing = "Assign Existing Member"
create_new = "Create New Member"
delete_selected = "Delete Selected"
remove = "Exclude from Organization"
+org_picker_title = "Select Organization"
view_org_chart = "View Full Org Chart"
direct_label = "Direct"
list_title = "Member Management"
diff --git a/locales/ko.toml b/locales/ko.toml
index ef0712e2..676f7743 100644
--- a/locales/ko.toml
+++ b/locales/ko.toml
@@ -1871,6 +1871,7 @@ add_existing = "기존 멤버 배정"
create_new = "신규 멤버 생성"
delete_selected = "선택 삭제"
remove = "조직에서 제외"
+org_picker_title = "조직 선택"
view_org_chart = "전체 조직도 보기"
direct_label = "직속"
list_title = "구성원 관리"
diff --git a/locales/template.toml b/locales/template.toml
index 2a3ea4c3..2d3b2c70 100644
--- a/locales/template.toml
+++ b/locales/template.toml
@@ -1730,6 +1730,7 @@ add_existing = ""
create_new = ""
delete_selected = ""
remove = ""
+org_picker_title = ""
view_org_chart = ""
direct_label = ""
list_title = ""
diff --git a/orgfront/src/features/orgchart/pickerTypes.ts b/orgfront/src/features/orgchart/pickerTypes.ts
index 1b14f9eb..2644eec9 100644
--- a/orgfront/src/features/orgchart/pickerTypes.ts
+++ b/orgfront/src/features/orgchart/pickerTypes.ts
@@ -8,6 +8,7 @@ export type OrgPickerSelection = {
type: OrgPickerObjectType;
id: string;
name: string;
+ email?: string;
};
export type OrgPickerResult = {
diff --git a/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx
new file mode 100644
index 00000000..351bf0fb
--- /dev/null
+++ b/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx
@@ -0,0 +1,266 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { OrgPickerEmbedPage } from "./OrgPickerPage";
+
+const adminApiMocks = vi.hoisted(() => ({
+ fetchAllTenants: vi.fn(),
+ fetchOrgChartSnapshot: vi.fn(),
+ fetchPublicOrgChart: vi.fn(),
+ fetchUsers: vi.fn(),
+}));
+
+vi.mock("../../../lib/adminApi", () => adminApiMocks);
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const now = "2026-06-10T00:00:00.000Z";
+
+function tenant({
+ id,
+ name,
+ slug,
+ type,
+ parentId,
+}: {
+ id: string;
+ name: string;
+ slug: string;
+ type: string;
+ parentId?: string;
+}) {
+ return {
+ id,
+ type,
+ name,
+ slug,
+ description: "",
+ status: "active",
+ parentId,
+ memberCount: 0,
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+function user({
+ id,
+ name,
+ tenantSlug,
+}: {
+ id: string;
+ name: string;
+ tenantSlug: string;
+}) {
+ return {
+ id,
+ email: `${id}@example.com`,
+ name,
+ role: "user",
+ status: "active",
+ tenantSlug,
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+const snapshot = {
+ tenants: [
+ tenant({
+ id: "group-1",
+ name: "Hanmac Family",
+ slug: "hanmac-family",
+ type: "COMPANY_GROUP",
+ }),
+ tenant({
+ id: "company-1",
+ name: "Snapshot Company",
+ slug: "snapshot-company",
+ type: "COMPANY",
+ parentId: "group-1",
+ }),
+ ],
+ users: [
+ user({
+ id: "user-1",
+ name: "Snapshot User",
+ tenantSlug: "snapshot-company",
+ }),
+ ],
+};
+
+const renderedPickers: ReturnType[] = [];
+
+function renderPicker(initialEntry: string) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ act(() => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+
+ const rendered = { container, queryClient, root };
+ renderedPickers.push(rendered);
+ return rendered;
+}
+
+async function flushQueries() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+
+async function waitForExpect(assertion: () => void) {
+ let lastError: unknown;
+
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ await flushQueries();
+ try {
+ assertion();
+ return;
+ } catch (error) {
+ lastError = error;
+ }
+ }
+
+ throw lastError;
+}
+
+function cleanupRendered({
+ container,
+ queryClient,
+ root,
+}: {
+ container: HTMLDivElement;
+ queryClient: QueryClient;
+ root: Root;
+}) {
+ act(() => {
+ root.unmount();
+ });
+ queryClient.clear();
+ container.remove();
+}
+
+describe("OrgPickerEmbedPage orgchart data source", () => {
+ afterEach(() => {
+ while (renderedPickers.length > 0) {
+ const rendered = renderedPickers.pop();
+ if (rendered) cleanupRendered(rendered);
+ }
+ vi.clearAllMocks();
+ });
+
+ it("uses the authenticated orgchart snapshot instead of legacy tenant and user list APIs", async () => {
+ adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
+ adminApiMocks.fetchPublicOrgChart.mockResolvedValue(snapshot);
+ adminApiMocks.fetchAllTenants.mockResolvedValue({
+ items: [],
+ limit: 100,
+ offset: 0,
+ total: 0,
+ });
+ adminApiMocks.fetchUsers.mockResolvedValue({
+ items: [],
+ limit: 5000,
+ offset: 0,
+ total: 0,
+ });
+
+ const rendered = renderPicker("/embed/picker?select=both");
+
+ await waitForExpect(() => {
+ expect(adminApiMocks.fetchOrgChartSnapshot).toHaveBeenCalledTimes(1);
+ expect(adminApiMocks.fetchPublicOrgChart).not.toHaveBeenCalled();
+ expect(adminApiMocks.fetchAllTenants).not.toHaveBeenCalled();
+ expect(adminApiMocks.fetchUsers).not.toHaveBeenCalledWith(5000, 0);
+ expect(rendered.container.textContent).toContain("Snapshot Company");
+ expect(rendered.container.textContent).toContain("Snapshot User");
+ });
+ });
+
+ it("uses the public orgchart snapshot when a share token is present", async () => {
+ adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
+ adminApiMocks.fetchPublicOrgChart.mockResolvedValue(snapshot);
+
+ const rendered = renderPicker(
+ "/embed/picker?token=public-token&select=user",
+ );
+
+ await waitForExpect(() => {
+ expect(adminApiMocks.fetchPublicOrgChart).toHaveBeenCalledTimes(1);
+ expect(adminApiMocks.fetchPublicOrgChart).toHaveBeenCalledWith(
+ "public-token",
+ );
+ expect(adminApiMocks.fetchOrgChartSnapshot).not.toHaveBeenCalled();
+ expect(adminApiMocks.fetchAllTenants).not.toHaveBeenCalled();
+ expect(adminApiMocks.fetchUsers).not.toHaveBeenCalledWith(5000, 0);
+ expect(rendered.container.textContent).toContain("Snapshot User");
+ });
+ });
+
+ it("allows tenant checks in user-only multi mode but confirms descendant users only", async () => {
+ adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
+ const postMessageSpy = vi.spyOn(window.parent, "postMessage");
+
+ const rendered = renderPicker(
+ "/embed/picker?mode=multiple&select=user&includeDescendants=true",
+ );
+
+ await waitForExpect(() => {
+ expect(rendered.container.textContent).toContain("Snapshot Company");
+ expect(rendered.container.textContent).toContain("Snapshot User");
+ });
+
+ const tenantCheckbox = rendered.container.querySelector(
+ 'input[aria-label="Snapshot Company 선택"]',
+ );
+ expect(tenantCheckbox).not.toBeNull();
+
+ await act(async () => {
+ tenantCheckbox?.click();
+ });
+
+ const confirmButton = Array.from(
+ rendered.container.querySelectorAll("button"),
+ ).find((button) => button.textContent?.includes("선택 완료"));
+ expect(confirmButton).toBeDefined();
+
+ await act(async () => {
+ confirmButton?.click();
+ });
+
+ expect(postMessageSpy).toHaveBeenCalledWith(
+ {
+ type: "orgfront:picker:confirm",
+ payload: {
+ mode: "multiple",
+ selections: [
+ {
+ type: "user",
+ id: "user-1",
+ name: "Snapshot User",
+ email: "user-1@example.com",
+ },
+ ],
+ },
+ },
+ "*",
+ );
+ });
+});
diff --git a/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx b/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
index 4199d5a3..d0b122e6 100644
--- a/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
+++ b/orgfront/src/features/orgchart/routes/OrgPickerPage.tsx
@@ -3,7 +3,10 @@ import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import * as React from "react";
import { useLocation } from "react-router-dom";
import { Button } from "../../../components/ui/button";
-import { fetchAllTenants, fetchUsers } from "../../../lib/adminApi";
+import {
+ fetchOrgChartSnapshot,
+ fetchPublicOrgChart,
+} from "../../../lib/adminApi";
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
import {
buildOrgPickerEmbedSrc,
@@ -26,7 +29,27 @@ function canSelectNode(
return select === "both" || select === node.type;
}
+function canToggleNode(
+ node: OrgPickerTreeNode,
+ mode: OrgPickerMode,
+ select: OrgPickerSelectableType,
+) {
+ return (
+ canSelectNode(node, select) ||
+ (mode === "multiple" && select === "user" && node.type === "tenant")
+ );
+}
+
function toSelection(node: OrgPickerTreeNode): OrgPickerSelection {
+ if (node.type === "user") {
+ return {
+ type: node.type,
+ id: node.id,
+ name: node.name,
+ email: node.user?.email,
+ };
+ }
+
return {
type: node.type,
id: node.id,
@@ -48,8 +71,10 @@ function collectSelectedNodes({
const selected = new Map();
const visit = (node: OrgPickerTreeNode) => {
const key = nodeKey(node);
- if (selectedKeys.has(key) && canSelectNode(node, select)) {
- selected.set(key, node);
+ if (selectedKeys.has(key)) {
+ if (canSelectNode(node, select)) {
+ selected.set(key, node);
+ }
if (includeDescendants && node.type === "tenant") {
for (const descendant of flattenDescendants(node)) {
if (canSelectNode(descendant, select)) {
@@ -230,6 +255,7 @@ function OrgPickerTreeItem({
}) {
const [isOpen, setIsOpen] = React.useState(true);
const selectable = canSelectNode(node, select);
+ const toggleable = canToggleNode(node, mode, select);
const hasChildren = node.children.length > 0;
const key = nodeKey(node);
const checked = selectedKeys.has(key);
@@ -280,7 +306,7 @@ function OrgPickerTreeItem({
)}
- {mode === "multiple" && selectable ? (
+ {mode === "multiple" && toggleable ? (
new Set(),
);
- const tenantsQuery = useQuery({
- queryKey: ["org-picker-tenants"],
- queryFn: () => fetchAllTenants(),
- });
- const usersQuery = useQuery({
- queryKey: ["org-picker-users"],
- queryFn: () => fetchUsers(5000, 0),
+ const orgChartQuery = useQuery({
+ queryKey: [
+ "org-picker-orgchart",
+ shareToken ? "public" : "authenticated",
+ shareToken,
+ ],
+ queryFn: () =>
+ shareToken ? fetchPublicOrgChart(shareToken) : fetchOrgChartSnapshot(),
});
React.useEffect(() => {
@@ -365,19 +393,12 @@ export function OrgPickerEmbedPage() {
const tree = React.useMemo(() => {
return buildOrgPickerTree({
includeInternal,
- tenants: tenantsQuery.data?.items ?? [],
- users: select === "tenant" ? [] : (usersQuery.data?.items ?? []),
+ tenants: orgChartQuery.data?.tenants ?? [],
+ users: select === "tenant" ? [] : (orgChartQuery.data?.users ?? []),
rootTenantId,
tenantId,
});
- }, [
- includeInternal,
- rootTenantId,
- select,
- tenantId,
- tenantsQuery.data,
- usersQuery.data,
- ]);
+ }, [includeInternal, orgChartQuery.data, rootTenantId, select, tenantId]);
const selectedItems = React.useMemo(
() =>
@@ -430,8 +451,8 @@ export function OrgPickerEmbedPage() {
postPickerMessage({ type: "orgfront:picker:cancel" });
};
- const isLoading = tenantsQuery.isLoading || usersQuery.isLoading;
- const isError = tenantsQuery.isError || usersQuery.isError;
+ const isLoading = orgChartQuery.isLoading;
+ const isError = orgChartQuery.isError;
React.useEffect(() => {
const htmlOverflow = document.documentElement.style.overflow;
diff --git a/orgfront/tests/orgchart-picker.spec.ts b/orgfront/tests/orgchart-picker.spec.ts
index cd89a77c..fca21c48 100644
--- a/orgfront/tests/orgchart-picker.spec.ts
+++ b/orgfront/tests/orgchart-picker.spec.ts
@@ -178,6 +178,10 @@ async function installOrgPickerApiMock(
}),
user("user-sales", "Sales User", "sales"),
];
+ const orgChartSnapshot = {
+ tenants,
+ users,
+ };
await page.route("**/api/v1/admin/tenants**", async (route) => {
await route.fulfill({
@@ -202,6 +206,20 @@ async function installOrgPickerApiMock(
}),
});
});
+
+ await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(orgChartSnapshot),
+ });
+ });
+
+ await page.route("**/api/v1/public/orgchart**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ ...orgChartSnapshot, sharedWith: "playwright" }),
+ });
+ });
}
test.beforeEach(async ({ page }) => {
@@ -294,6 +312,18 @@ test("picker defaults to the hanmac-family company-group when no tenant id is su
}),
});
});
+ await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ tenants, users: [] }),
+ });
+ });
+ await page.route("**/api/v1/public/orgchart**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ tenants, users: [], sharedWith: "playwright" }),
+ });
+ });
await page.goto(withShareToken("/picker"));
@@ -351,6 +381,18 @@ test("embed preview picker orders hanmac-family tenants by the shared policy", a
}),
});
});
+ await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ tenants, users: [] }),
+ });
+ });
+ await page.route("**/api/v1/public/orgchart**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ tenants, users: [], sharedWith: "playwright" }),
+ });
+ });
await page.goto(withShareToken("/embed-preview?select=tenant"));
@@ -644,6 +686,25 @@ test("embed picker posts a single user selection with type, id, and name", async
await expect(output).not.toContainText("tenantId");
});
+test("embed picker lets user-only multi selection check tenants but posts descendant users only", async ({
+ page,
+}) => {
+ await page.goto(withShareToken("/embed-preview?mode=multiple&select=user"));
+
+ const picker = page.frameLocator("iframe");
+ await picker.getByLabel("Engineering 선택", { exact: true }).check();
+ await expect(
+ picker.getByLabel("Platform User 책임 선택", { exact: true }),
+ ).toBeChecked();
+ await picker.getByRole("button", { name: "선택 완료" }).click();
+
+ const output = page.getByTestId("embed-preview-output");
+ await expect(output).toContainText('"id": "user-eng"');
+ await expect(output).toContainText('"id": "user-platform"');
+ await expect(output).not.toContainText('"id": "dept-eng"');
+ await expect(output).not.toContainText('"id": "team-platform"');
+});
+
test("embed picker single selection counts only the selected node without descendants", async ({
page,
}) => {
diff --git a/test/code_check_userfront_locale_trigger_test.sh b/test/code_check_userfront_locale_trigger_test.sh
new file mode 100644
index 00000000..578a1f78
--- /dev/null
+++ b/test/code_check_userfront_locale_trigger_test.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+WORKFLOW_FILE="$ROOT_DIR/.gitea/workflows/code_check.yml"
+
+fail() {
+ echo "ERROR: $*" >&2
+ exit 1
+}
+
+i18n_shared="$(
+ sed -n "s/^[[:space:]]*i18n_shared='\(.*\)'/\1/p" "$WORKFLOW_FILE"
+)"
+
+if [ -z "$i18n_shared" ]; then
+ fail "could not find i18n_shared pattern in Code Check workflow"
+fi
+
+assert_i18n_shared_match() {
+ local path="$1"
+ printf '%s\n' "$path" | grep -Eq "$i18n_shared" ||
+ fail "Code Check i18n_shared must match root/userfront locale path: $path"
+}
+
+assert_i18n_shared_match "locales/template.toml"
+assert_i18n_shared_match "locales/ko.toml"
+assert_i18n_shared_match "locales/en.toml"
+assert_i18n_shared_match "common/locales/template.toml"
+assert_i18n_shared_match "userfront/assets/translations/ko.toml"
+
+grep -Fq 'if matches "$global|$i18n_shared|^userfront/"; then userfront=true; fi' \
+ "$WORKFLOW_FILE" || fail "userfront output must depend on i18n_shared"
+grep -Fq 'if matches "$global|$i18n_shared|^userfront/|^scripts/summarize_flutter_coverage\.mjs"; then userfront_coverage=true; fi' \
+ "$WORKFLOW_FILE" || fail "userfront_coverage output must depend on i18n_shared"
+grep -Fq 'if matches "$global|$i18n_shared|^userfront/|^userfront-e2e/"; then userfront_e2e=true; fi' \
+ "$WORKFLOW_FILE" || fail "userfront_e2e output must depend on i18n_shared"
+
+echo "OK: root locale changes trigger userfront Code Check jobs"
diff --git a/test/kratos_identity_write_path_policy_test.sh b/test/kratos_identity_write_path_policy_test.sh
index 2bb73974..05e6bdbe 100755
--- a/test/kratos_identity_write_path_policy_test.sh
+++ b/test/kratos_identity_write_path_policy_test.sh
@@ -13,6 +13,7 @@ backend/internal/bootstrap/kratos_seed.go
backend/internal/handler/auth_handler.go
backend/internal/handler/user_handler.go
backend/internal/service/kratos_admin_service.go
+backend/internal/service/identity_write_service.go
backend/internal/service/ory_service.go
backend/internal/service/user_group_service.go
scripts/clear_orphan_tenant_memberships.sh
diff --git a/userfront-e2e/tests/auth-routing.spec.ts b/userfront-e2e/tests/auth-routing.spec.ts
index 1e9d4942..51bb15b7 100644
--- a/userfront-e2e/tests/auth-routing.spec.ts
+++ b/userfront-e2e/tests/auth-routing.spec.ts
@@ -157,10 +157,16 @@ async function mockUserfrontApis(
function collectClientFailures(page: Page): string[] {
const failures: string[] = [];
page.on("pageerror", (error) => {
- failures.push(error.message);
+ const text = error.message.trim();
+ if (text !== "") {
+ failures.push(text);
+ }
});
page.on("console", (message) => {
- const text = message.text();
+ const text = message.text().trim();
+ if (text === "") {
+ return;
+ }
if (
message.type() === "error" ||
(/exception|verify_failed|verification failed|인증 실패/i.test(text) &&
diff --git a/userfront-e2e/tests/login-performance-budget.spec.ts b/userfront-e2e/tests/login-performance-budget.spec.ts
index 4c0a0eb7..7c5c4c3a 100644
--- a/userfront-e2e/tests/login-performance-budget.spec.ts
+++ b/userfront-e2e/tests/login-performance-budget.spec.ts
@@ -117,6 +117,7 @@ function expectNoDuplicateStaticRequests(metrics: LoadMetrics): void {
!path.endsWith("/") &&
!path.endsWith("/main.dart.wasm") &&
!path.endsWith("/main.dart.mjs") &&
+ !path.endsWith("/assets/AssetManifest.bin.json") &&
!path.endsWith("/skwasm.js") &&
!path.endsWith("/skwasm.wasm")
);
@@ -129,11 +130,14 @@ function resolvePerformanceBudget(projectName: string): {
coldMs: number;
warmMs: number;
} {
+ if (projectName === "webkit-mobile-webapp") {
+ return { coldMs: 10_000, warmMs: 4000 };
+ }
if (projectName.includes("webkit")) {
return { coldMs: 4000, warmMs: 4000 };
}
if (projectName.includes("firefox")) {
- return { coldMs: 2600, warmMs: 2800 };
+ return { coldMs: 3000, warmMs: 2800 };
}
if (projectName.includes("mobile")) {
return { coldMs: 3000, warmMs: 2300 };
diff --git a/userfront-e2e/tests/profile-department.spec.ts b/userfront-e2e/tests/profile-department.spec.ts
index 5f203e5b..fcd75f35 100644
--- a/userfront-e2e/tests/profile-department.spec.ts
+++ b/userfront-e2e/tests/profile-department.spec.ts
@@ -215,6 +215,12 @@ async function blurDepartmentEditor(page: Page): Promise {
}
async function submitDepartmentEditor(page: Page): Promise {
+ const saveButton = page.getByRole("button", { name: "저장" });
+ if ((await saveButton.count()) > 0) {
+ await saveButton.click({ force: true });
+ await page.waitForTimeout(250);
+ return;
+ }
const textbox = page.getByRole("textbox", { name: "소속" });
if ((await textbox.count()) > 0) {
await textbox.press("Enter");
@@ -230,22 +236,12 @@ async function submitDepartmentEditor(page: Page): Promise {
async function fillDepartmentField(page: Page, value: string): Promise {
const textbox = page.getByRole("textbox", { name: "소속" });
- if (!isMobileProject(page)) {
- if ((await textbox.count()) > 0) {
- await textbox.click({ force: true });
- await page.waitForTimeout(100);
- }
- const coords = coordsFor(page);
- await fillAt(page, coords.departmentInputX, coords.departmentInputY, value);
+ if ((await textbox.count()) > 0) {
+ await textbox.fill(value);
+ await page.waitForTimeout(100);
return;
}
- if ((await textbox.count()) > 0) {
- await textbox.click({ force: true });
- await page.waitForTimeout(100);
- await replaceFocusedText(page, value);
- return;
- }
if (isMobileProject(page)) {
throw new Error("Department textbox was not found.");
}
diff --git a/userfront-e2e/tests/route-inventory.spec.ts b/userfront-e2e/tests/route-inventory.spec.ts
index ac7fbc8f..f4e06d38 100644
--- a/userfront-e2e/tests/route-inventory.spec.ts
+++ b/userfront-e2e/tests/route-inventory.spec.ts
@@ -1,4 +1,10 @@
-import { expect, type Page, type Route, test } from "@playwright/test";
+import {
+ expect,
+ type Page,
+ type Route,
+ test,
+ type TestInfo,
+} from "@playwright/test";
async function seedTokenLogin(page: Page): Promise {
await page.addInitScript(() => {
@@ -156,133 +162,157 @@ async function mockInventoryApis(page: Page): Promise {
});
}
+async function expectRouteUrl(
+ page: Page,
+ expected: RegExp,
+ testInfo: TestInfo,
+): Promise {
+ await expect(page).toHaveURL(expected, {
+ timeout: testInfo.project.name.includes("webkit") ? 15_000 : 5_000,
+ });
+}
+
test.describe("UserFront WASM route inventory (unauth)", () => {
test.beforeEach(async ({ page }) => {
await mockInventoryApis(page);
});
- test("route: /", async ({ page }) => {
+ test("route: /", async ({ page }, testInfo) => {
await page.goto("/");
- await expect(page).toHaveURL(/\/(ko|en)\/signin(?:\?.*)?$/);
+ await expectRouteUrl(page, /\/(ko|en)\/signin(?:\?.*)?$/, testInfo);
});
- test("route: /ko", async ({ page }) => {
+ test("route: /ko", async ({ page }, testInfo) => {
await page.goto("/ko");
- await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
+ await expectRouteUrl(page, /\/ko\/signin(?:\?.*)?$/, testInfo);
});
- test("route: /ko/dashboard", async ({ page }) => {
+ test("route: /ko/dashboard", async ({ page }, testInfo) => {
await page.goto("/ko/dashboard");
- await expect(page).toHaveURL(/\/ko\/signin$/);
+ await expectRouteUrl(page, /\/ko\/signin$/, testInfo);
});
- test("route: /ko/profile", async ({ page }) => {
+ test("route: /ko/profile", async ({ page }, testInfo) => {
await page.goto("/ko/profile");
- await expect(page).toHaveURL(/\/ko\/signin$/);
+ await expectRouteUrl(page, /\/ko\/signin$/, testInfo);
});
- test("route: /ko/admin/users", async ({ page }) => {
+ test("route: /ko/admin/users", async ({ page }, testInfo) => {
await page.goto("/ko/admin/users");
- await expect(page).toHaveURL(/\/ko\/signin$/);
+ await expectRouteUrl(page, /\/ko\/signin$/, testInfo);
});
- test("route: /ko/scan", async ({ page }) => {
+ test("route: /ko/scan", async ({ page }, testInfo) => {
await page.goto("/ko/scan");
- await expect(page).toHaveURL(/\/ko\/signin$/);
+ await expectRouteUrl(page, /\/ko\/signin$/, testInfo);
});
- test("route: /ko/signin", async ({ page }) => {
+ test("route: /ko/signin", async ({ page }, testInfo) => {
await page.goto("/ko/signin");
- await expect(page).toHaveURL(/\/ko\/signin$/);
+ await expectRouteUrl(page, /\/ko\/signin$/, testInfo);
});
- test("route: /ko/login", async ({ page }) => {
+ test("route: /ko/login", async ({ page }, testInfo) => {
await page.goto("/ko/login");
- await expect(page).toHaveURL(/\/ko\/login$/);
+ await expectRouteUrl(page, /\/ko\/login$/, testInfo);
});
- test("route: /ko/signup", async ({ page }) => {
+ test("route: /ko/signup", async ({ page }, testInfo) => {
await page.goto("/ko/signup");
- await expect(page).toHaveURL(/\/ko\/signup$/);
+ await expectRouteUrl(page, /\/ko\/signup$/, testInfo);
});
- test("route: /ko/registration", async ({ page }) => {
+ test("route: /ko/registration", async ({ page }, testInfo) => {
await page.goto("/ko/registration");
- await expect(page).toHaveURL(/\/ko\/registration$/);
+ await expectRouteUrl(page, /\/ko\/registration$/, testInfo);
});
- test("route: /ko/verify", async ({ page }) => {
+ test("route: /ko/verify", async ({ page }, testInfo) => {
await page.goto("/ko/verify");
- await expect(page).toHaveURL(/\/ko\/verify$/);
+ await expectRouteUrl(page, /\/ko\/verify$/, testInfo);
});
- test("route: /ko/verify/:token", async ({ page }) => {
+ test("route: /ko/verify/:token", async ({ page }, testInfo) => {
await page.goto("/ko/verify/e2e-token");
- await expect(page).toHaveURL(/\/ko\/verify\/e2e-token$/);
+ await expectRouteUrl(page, /\/ko\/verify\/e2e-token$/, testInfo);
});
- test("route: /ko/verification", async ({ page }) => {
+ test("route: /ko/verification", async ({ page }, testInfo) => {
await page.goto("/ko/verification");
- await expect(page).toHaveURL(/\/ko\/verification$/);
+ await expectRouteUrl(page, /\/ko\/verification$/, testInfo);
});
- test("route: /ko/verify-complete", async ({ page }) => {
+ test("route: /ko/verify-complete", async ({ page }, testInfo) => {
await page.goto("/ko/verify-complete");
- await expect(page).toHaveURL(/\/ko\/verify-complete$/);
+ await expectRouteUrl(page, /\/ko\/verify-complete$/, testInfo);
});
- test("route: /ko/l/:shortCode", async ({ page }) => {
+ test("route: /ko/l/:shortCode", async ({ page }, testInfo) => {
await page.goto("/ko/l/AB123456");
- await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
+ await expectRouteUrl(page, /\/ko\/l\/AB123456$/, testInfo);
});
- test("route: /ko/forgot-password", async ({ page }) => {
+ test("route: /ko/forgot-password", async ({ page }, testInfo) => {
await page.goto("/ko/forgot-password");
- await expect(page).toHaveURL(/\/ko\/forgot-password$/);
+ await expectRouteUrl(page, /\/ko\/forgot-password$/, testInfo);
});
- test("route: /ko/recovery", async ({ page }) => {
+ test("route: /ko/recovery", async ({ page }, testInfo) => {
await page.goto("/ko/recovery");
- await expect(page).toHaveURL(/\/ko\/recovery$/);
+ await expectRouteUrl(page, /\/ko\/recovery$/, testInfo);
});
- test("route: /ko/reset-password", async ({ page }) => {
+ test("route: /ko/reset-password", async ({ page }, testInfo) => {
await page.goto("/ko/reset-password?token=e2e-reset-token");
- await expect(page).toHaveURL(
+ await expectRouteUrl(
+ page,
/\/ko\/reset-password\?token=e2e-reset-token$/,
+ testInfo,
);
});
- test("route: /ko/error", async ({ page }) => {
+ test("route: /ko/error", async ({ page }, testInfo) => {
await page.goto("/ko/error?error=invalid_request");
- await expect(page).toHaveURL(/\/ko\/error\?error=invalid_request$/);
+ await expectRouteUrl(page, /\/ko\/error\?error=invalid_request$/, testInfo);
});
- test("route: /ko/settings", async ({ page }) => {
+ test("route: /ko/settings", async ({ page }, testInfo) => {
await page.goto("/ko/settings");
- await expect(page).toHaveURL(/\/ko\/settings$/);
+ await expectRouteUrl(page, /\/ko\/settings$/, testInfo);
});
- test("route: /ko/consent (missing challenge)", async ({ page }) => {
+ test("route: /ko/consent (missing challenge)", async ({ page }, testInfo) => {
await page.goto("/ko/consent");
- await expect(page).toHaveURL(/\/ko\/consent$/);
+ await expectRouteUrl(page, /\/ko\/consent$/, testInfo);
});
- test("route: /ko/consent?consent_challenge=...", async ({ page }) => {
+ test("route: /ko/consent?consent_challenge=...", async ({
+ page,
+ }, testInfo) => {
await page.goto("/ko/consent?consent_challenge=e2e-consent");
- await expect(page).toHaveURL(
+ await expectRouteUrl(
+ page,
/\/ko\/consent\?consent_challenge=e2e-consent$/,
+ testInfo,
);
});
- test("route: /ko/approve?ref=...", async ({ page }) => {
+ test("route: /ko/approve?ref=...", async ({ page }, testInfo) => {
await page.goto("/ko/approve?ref=e2e-ref");
- await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
+ await expectRouteUrl(
+ page,
+ /\/ko\/signin\?notice=qr_login_required$/,
+ testInfo,
+ );
});
- test("route: /ko/ql/:ref", async ({ page }) => {
+ test("route: /ko/ql/:ref", async ({ page }, testInfo) => {
await page.goto("/ko/ql/e2e-ref");
- await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
+ await expectRouteUrl(
+ page,
+ /\/ko\/signin\?notice=qr_login_required$/,
+ testInfo,
+ );
});
});
@@ -292,44 +322,40 @@ test.describe("UserFront WASM route inventory (authed)", () => {
await mockInventoryApis(page);
});
- test("route: /ko -> /ko/dashboard", async ({ page }) => {
+ test("route: /ko -> /ko/dashboard", async ({ page }, testInfo) => {
await page.goto("/ko");
- await expect(page).toHaveURL(/\/ko\/dashboard$/);
+ await expectRouteUrl(page, /\/ko\/dashboard$/, testInfo);
});
- test("route: /ko/dashboard", async ({ page }) => {
+ test("route: /ko/dashboard", async ({ page }, testInfo) => {
await page.goto("/ko/dashboard");
- await expect(page).toHaveURL(/\/ko\/dashboard$/);
+ await expectRouteUrl(page, /\/ko\/dashboard$/, testInfo);
});
- test("route: /ko/profile", async ({ page }) => {
+ test("route: /ko/profile", async ({ page }, testInfo) => {
await page.goto("/ko/profile");
- await expect(page).toHaveURL(/\/ko\/profile$/);
+ await expectRouteUrl(page, /\/ko\/profile$/, testInfo);
});
- test("route: /ko/admin/users", async ({ page }) => {
+ test("route: /ko/admin/users", async ({ page }, testInfo) => {
await page.goto("/ko/admin/users");
- await expect(page).toHaveURL(/\/ko\/admin\/users$/);
+ await expectRouteUrl(page, /\/ko\/admin\/users$/, testInfo);
});
- test("route: /ko/scan", async ({ page }) => {
+ test("route: /ko/scan", async ({ page }, testInfo) => {
await page.goto("/ko/scan");
- await expect(page).toHaveURL(/\/ko\/scan$/);
+ await expectRouteUrl(page, /\/ko\/scan$/, testInfo);
});
test("route: /ko/approve?ref=... -> /ko/dashboard", async ({
page,
}, testInfo) => {
await page.goto("/ko/approve?ref=e2e-ref");
- await expect(page).toHaveURL(/\/ko\/dashboard$/, {
- timeout: testInfo.project.name === "webkit-desktop" ? 15_000 : 5_000,
- });
+ await expectRouteUrl(page, /\/ko\/dashboard$/, testInfo);
});
test("route: /ko/ql/:ref -> /ko/dashboard", async ({ page }, testInfo) => {
await page.goto("/ko/ql/e2e-ref");
- await expect(page).toHaveURL(/\/ko\/dashboard$/, {
- timeout: testInfo.project.name === "webkit-desktop" ? 15_000 : 5_000,
- });
+ await expectRouteUrl(page, /\/ko\/dashboard$/, testInfo);
});
});
diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock
index 8b6fff8c..b23d80a9 100644
--- a/userfront/pubspec.lock
+++ b/userfront/pubspec.lock
@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
- sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
- version: "1.4.0"
+ version: "1.4.1"
cli_config:
dependency: transitive
description:
@@ -268,14 +268,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
- js:
- dependency: transitive
- description:
- name: js
- sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
- url: "https://pub.dev"
- source: hosted
- version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -328,18 +320,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
+ sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
- version: "0.12.17"
+ version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
- version: "0.11.1"
+ version: "0.13.0"
meta:
dependency: transitive
description:
@@ -661,26 +653,26 @@ packages:
dependency: transitive
description:
name: test
- sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
+ sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
- version: "1.26.3"
+ version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
- sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
+ sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
- version: "0.7.7"
+ version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
- sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
+ sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
- version: "0.6.12"
+ version: "0.6.16"
toml:
dependency: "direct main"
description:
|