diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 5900db79..dd28117a 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -56,6 +56,16 @@ import { } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; +type UserSchemaField = { + key: string; + label?: string; + type?: "text" | "number" | "boolean" | "date"; + required?: boolean; + adminOnly?: boolean; + validation?: string; + isLoginId?: boolean; +}; + type UserFormValues = UserCreateRequest & { metadata: Record }; type UserType = "hanmac" | "external" | "personal"; diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 958685cd..07a94ca3 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -79,6 +79,16 @@ import { } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; +type UserSchemaField = { + key: string; + label?: string; + type?: "text" | "number" | "boolean" | "date"; + required?: boolean; + adminOnly?: boolean; + validation?: string; + isLoginId?: boolean; +}; + type UserFormValues = Omit & { metadata: Record>; }; diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 4d4c37a0..2bff1771 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -119,6 +119,7 @@ test.describe("Tenants Management", () => { test("should export and import tenant CSV without organization/user combined import", async ({ page, + browserName, }, testInfo) => { let exportRequested = false; let exportUrl = ""; @@ -213,9 +214,12 @@ test.describe("Tenants Management", () => { /갱신 1|Updated 1/i, ); expect(importRequested).toBe(true); - if (testInfo.project.name !== "webkit") { + expect(importBody).toContain('filename="tenants.csv"'); + if (browserName !== "webkit") { + if (testInfo.project.name !== "webkit") { expect(importBody).toContain("tenant-alpha-id"); } + } }); test("should resolve tenant CSV conflicts by choosing create and remapping parent ids", async ({ diff --git a/backend/internal/handler/dev_handler_isolation_test.go b/backend/internal/handler/dev_handler_isolation_test.go index a73afaef..401cf035 100644 --- a/backend/internal/handler/dev_handler_isolation_test.go +++ b/backend/internal/handler/dev_handler_isolation_test.go @@ -192,7 +192,7 @@ func TestDevHandler_Isolation(t *testing.T) { assert.Equal(t, http.StatusForbidden, resp.StatusCode) }) - t.Run("UpdateClient should enforce tenant isolation", func(t *testing.T) { + t.Run("UpdateClient should require direct edit permission within tenant isolation", func(t *testing.T) { app := fiber.New() tenantA := "tenant-a" app.Use(func(c *fiber.Ctx) error { @@ -209,11 +209,11 @@ func TestDevHandler_Isolation(t *testing.T) { "client_name": "Updated Name", }) - // Case 1: Same tenant + // Case 1: Same tenant but no direct edit_config permission req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-a", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) - assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) // Case 2: Different tenant req = httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-tenant-b", bytes.NewReader(body)) diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index abbab331..61dced27 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -348,7 +348,9 @@ function AuditLogsPage() { ) : null} - {actionLabel} + + {actionLabel} +
{targetValue} @@ -401,16 +403,21 @@ function AuditLogsPage() {
- Request ID: {formatValue(details.request_id)} + Request ID:{" "} + {formatValue(details.request_id)} +
+
+ Method: {formatValue(details.method)}
-
Method: {formatValue(details.method)}
Path: {formatValue(details.path)}
Tenant: {formatValue(details.tenant_id)}
-
Before: {formatValue(details.before)}
+
+ Before: {formatValue(details.before)} +
After: {formatValue(details.after)}
Error: {formatValue(details.error)}
diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 7a502957..4657e624 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -218,10 +218,12 @@ function ClientDetailsPage() { const clientSecret = hasClientSecret ? client?.clientSecret || secretPlaceholder : t("ui.common.na", "N/A"); - const displaySecret = - !hasClientSecret - ? t("msg.dev.clients.details.secret_not_applicable", "PKCE 앱에는 Client Secret이 없습니다.") - : clientSecret === secretPlaceholder + const displaySecret = !hasClientSecret + ? t( + "msg.dev.clients.details.secret_not_applicable", + "PKCE 앱에는 Client Secret이 없습니다.", + ) + : clientSecret === secretPlaceholder ? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE") : clientSecret; diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 3f7db64b..79352aa2 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -463,9 +463,7 @@ function ClientGeneralPage() { (item.relation === "admins" || item.relation === "config_editor"), ) === true; const isGeneralSettingsReadOnly = - !isCreate && - relationData != null && - !canEditExistingClientGeneralSettings; + !isCreate && relationData != null && !canEditExistingClientGeneralSettings; const trimmedLogoUrl = logoUrl.trim(); const trimmedAutoLoginUrl = autoLoginUrl.trim(); const hasLogoUrl = trimmedLogoUrl.length > 0; diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index d5e317c2..3f6c1e2d 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -312,10 +312,13 @@ function ClientRelationsPage() { } }; - const handleInfoToggle = (event: React.MouseEvent, relation: RelationOption) => { + const handleInfoToggle = ( + event: React.MouseEvent, + relation: RelationOption, + ) => { event.preventDefault(); event.stopPropagation(); - setInfoRelation(prev => (prev === relation ? null : relation)); + setInfoRelation((prev) => (prev === relation ? null : relation)); }; if (!clientId) { @@ -401,7 +404,10 @@ function ClientRelationsPage() { {isLoading ? (
- {t("msg.dev.clients.relationships.loading", "Loading relationships...")} + {t( + "msg.dev.clients.relationships.loading", + "Loading relationships...", + )}
) : isRelationshipViewForbidden ? (
@@ -510,7 +516,7 @@ function ClientRelationsPage() { selectedUserExistingRelations.has(relation); const isSelected = selectedRelations.includes(relation); const isInfoVisible = infoRelation === relation; - + return (
- + {isInfoVisible && (
@@ -673,7 +679,9 @@ function ClientRelationsPage() {
- {relationLabel(item.relation as RelationOption)} + + {relationLabel(item.relation as RelationOption)} +
{infoRelation === item.relation && (
- {relationPermitsInfo(item.relation as RelationOption)} + {relationPermitsInfo( + item.relation as RelationOption, + )}
)}
diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 0644f030..797e5913 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -191,11 +191,17 @@ delete_confirm = "" delete_success = "" empty = "" fetch_error = "" +import_empty = "" +import_error = "" +import_result = "" missing_id = "" not_found = "" remove_sub_confirm = "" subtitle = "" +[msg.admin.tenants.import_preview] +description = "" + [msg.admin.tenants.admins] add_success = "" empty = "" @@ -255,9 +261,13 @@ parsed_count = "" update_success = "" [msg.admin.users.create] +appointment_required = "" error = "" +external_tenant_required = "" password_required = "" +personal_tenant_failed = "" success = "" +tenant_resolve_failed = "" [msg.admin.users.create.account] subtitle = "" @@ -269,6 +279,7 @@ field_required = "" name_required = "" password_auto_help = "" password_manual_help = "" +picker_description = "" role_help = "" [msg.admin.users.create.password_generated] @@ -291,7 +302,9 @@ password_hint = "" [msg.admin.users.list] delete_confirm = "" empty = "" +export_error = "" fetch_error = "" +status_error = "" subtitle = "" [msg.admin.users.list.columns] @@ -991,6 +1004,9 @@ user = "" [ui.admin.tenants] add = "" +csv_template = "" +export = "" +import = "" title = "" [ui.admin.tenants.admins] @@ -1120,6 +1136,15 @@ search_placeholder = "" title = "" tree_search_placeholder = "" +[ui.admin.tenants.import_preview] +candidates = "" +confirm = "" +create_new = "" +fixed_id = "" +match = "" +no_candidates = "" +title = "" + [ui.admin.tenants.sub.table] action = "" name = "" @@ -1128,6 +1153,7 @@ status = "" [ui.admin.tenants.table] actions = "" +id = "" members = "" name = "" slug = "" @@ -1227,6 +1253,7 @@ empty = "" fetch_error = "" search_placeholder = "" subtitle = "" +toggle_status = "" title = "" [ui.admin.users.list.breadcrumb] diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index 3f6ff964..3e1b0df5 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -221,6 +221,39 @@ function parseClientId(pathname: string): string { } export async function installDevApiMock(page: Page, state: DevApiMockState) { + const readMockRole = async () => + ( + (await page.evaluate(() => window.localStorage.getItem("dev_role"))) ?? + "rp_admin" + ).trim(); + + const buildSelfConfigEditorRelation = (): ClientRelation => ({ + relation: "config_editor", + subject: "User:playwright-user", + subjectType: "User", + subjectId: "playwright-user", + userName: "Playwright User", + userEmail: "playwright@example.com", + userLoginId: "playwright@example.com", + }); + + const shouldGrantDefaultEditRelation = (role: string) => + role === "rp_admin" || role === "tenant_admin" || role === "super_admin"; + + const resolveClientRelations = async (clientId: string) => { + const explicitRelations = state.relations?.[clientId]; + if (explicitRelations) { + return explicitRelations; + } + + const role = await readMockRole(); + if (!shouldGrantDefaultEditRelation(role)) { + return []; + } + + return [buildSelfConfigEditorRelation()]; + }; + const appendAuditLog = ( eventType: string, action: string, @@ -431,6 +464,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { }); state.clients.push(created); + if (!state.relations) { + state.relations = {}; + } + state.relations[created.id] = [buildSelfConfigEditorRelation()]; appendAuditLog("CLIENT_CREATE", "CREATE_CLIENT", created.id); return json(route, { client: created, @@ -451,7 +488,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { ) { const clientId = pathname.split("/")[5] ?? ""; return json(route, { - items: state.relations?.[clientId] ?? [], + items: await resolveClientRelations(clientId), }); } diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json index fd6bfb2d..4a0735da 100755 --- a/docker/ory/oathkeeper/rules.active.json +++ b/docker/ory/oathkeeper/rules.active.json @@ -156,4 +156,4 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] } -] +] \ No newline at end of file diff --git a/locales/en.toml b/locales/en.toml index f60f9a3f..cb2f0aa3 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -1204,6 +1204,15 @@ title = "Tenant Members ({{count}})" total = "Total" total_label = "Total" +[ui.admin.tenants.import_preview] +candidates = "Candidates" +confirm = "Confirm Import" +create_new = "Create New" +fixed_id = "Fixed ID" +match = "Matched Tenant" +no_candidates = "No matching tenants found." +title = "Import Preview" + [ui.admin.tenants.members.table] email = "EMAIL" name = "NAME" @@ -1339,6 +1348,7 @@ name = "Name" name_placeholder = "Name Placeholder" password = "Password" password_placeholder = "********" +picker_description = "Search and select a tenant." phone = "Phone number" phone_placeholder = "010-1234-5678" position = "Position" diff --git a/locales/ko.toml b/locales/ko.toml index 9795d21f..29f63aa3 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -672,11 +672,17 @@ delete_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"를 삭제할까요?" delete_success = "테넌트가 삭제되었습니다." empty = "아직 등록된 테넌트가 없습니다." fetch_error = "테넌트 목록 조회에 실패했습니다." +import_empty = "임포트 파일에 테넌트 행이 없습니다." +import_error = "테넌트 임포트에 실패했습니다: {{error}}" +import_result = "{{count}}개의 테넌트 행을 처리했습니다." missing_id = "테넌트 ID가 없습니다." not_found = "테넌트를 찾을 수 없습니다." remove_sub_confirm = "테넌트 \\\\\\\"{{name}}\\\\\\\"을(를) 하위 조직에서 제외할까요?" subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다." +[msg.admin.tenants.import_preview] +description = "임포트 전에 각 행의 매칭 결과를 검토하고 처리 방식을 선택하세요." + [msg.admin.tenants.admins] add_success = "관리자가 추가되었습니다." empty = "등록된 관리자가 없습니다." @@ -1645,6 +1651,8 @@ delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?" [msg.admin.users] self_delete_blocked = "자신의 계정은 삭제할 수 없습니다." +export_error = "사용자 내보내기에 실패했습니다: {{error}}" +status_error = "사용자 상태 변경에 실패했습니다: {{error}}" [ui.admin.apikeys.registry] title = "API Key Registry" @@ -1658,6 +1666,15 @@ title = "테넌트 구성원 ({{count}})" total = "전체" total_label = "전체" +[ui.admin.tenants.import_preview] +candidates = "후보" +confirm = "임포트 확정" +create_new = "새로 생성" +fixed_id = "고정 ID" +match = "매칭된 테넌트" +no_candidates = "매칭 가능한 테넌트가 없습니다." +title = "임포트 미리보기" + [ui.admin.tenants.members.table] email = "EMAIL" name = "NAME" @@ -1793,6 +1810,7 @@ name = "이름" name_placeholder = "홍길동" password = "비밀번호" password_placeholder = "********" +picker_description = "배정할 테넌트를 검색해서 선택하세요." phone = "전화번호" phone_placeholder = "010-1234-5678" position = "직급" diff --git a/locales/template.toml b/locales/template.toml index 7a3bbb00..fc9923c3 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -1517,9 +1517,17 @@ import_partial_success = "" [msg.admin.tenants] delete_bulk_confirm = "" +import_empty = "" +import_error = "" +import_result = "" + +[msg.admin.tenants.import_preview] +description = "" [msg.admin.users] self_delete_blocked = "" +export_error = "" +status_error = "" [ui.admin.apikeys.registry] title = "" @@ -1604,6 +1612,15 @@ search_placeholder = "" title = "" tree_search_placeholder = "" +[ui.admin.tenants.import_preview] +candidates = "" +confirm = "" +create_new = "" +fixed_id = "" +match = "" +no_candidates = "" +title = "" + [ui.admin.tenants.sub.table] action = "" name = "" @@ -1668,6 +1685,7 @@ name = "" name_placeholder = "" password = "" password_placeholder = "" +picker_description = "" phone = "" phone_placeholder = "" position = "" diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh index 2237686d..3b6549e3 100755 --- a/scripts/run_adminfront_ci_tests.sh +++ b/scripts/run_adminfront_ci_tests.sh @@ -6,8 +6,40 @@ job_name="${1:-adminfront-tests}" mkdir -p reports rm -rf adminfront/node_modules -playwright_install_cmd=(npx playwright install --with-deps) -playwright_install_desc="npx playwright install --with-deps" +is_port_available() { + local port="$1" + node -e ' + const net = require("net"); + const port = Number(process.argv[1]); + const server = net.createServer(); + server.once("error", () => process.exit(1)); + server.once("listening", () => server.close(() => process.exit(0))); + server.listen(port, "127.0.0.1"); + ' "$port" +} + +find_available_port() { + node -e ' + const net = require("net"); + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + process.stdout.write(String(address.port)); + server.close(); + }); + ' +} + +playwright_install_cmd=(npx playwright install) +playwright_install_desc="npx playwright install" + +if [ "$(id -u)" -eq 0 ]; then + playwright_install_cmd=(npx playwright install --with-deps) + playwright_install_desc="npx playwright install --with-deps" +elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then + playwright_install_cmd=(npx playwright install --with-deps) + playwright_install_desc="npx playwright install --with-deps" +fi set +e ( @@ -67,6 +99,11 @@ fi set +e port="${PORT:-5180}" +if ! is_port_available "$port"; then + fallback_port="$(find_available_port)" + echo "==> requested PORT=$port is already in use; switching to PORT=$fallback_port" + port="$fallback_port" +fi echo "==> adminfront using PORT=$port" ( cd adminfront