diff --git a/.env.sample b/.env.sample index 6b98db23..2200fd2d 100644 --- a/.env.sample +++ b/.env.sample @@ -180,6 +180,7 @@ VITE_OIDC_CLIENT_ID=devfront VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc DEVFRONT_URL=http://localhost:5174 DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback +ORGFRONT_URL=http://localhost:5175 ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback VITE_ORGCHART_URL= diff --git a/devfront/Dockerfile b/devfront/Dockerfile index 32d7cdef..6d18bdcd 100644 --- a/devfront/Dockerfile +++ b/devfront/Dockerfile @@ -14,9 +14,11 @@ COPY devfront ./devfront ARG VITE_DEVFRONT_PUBLIC_URL ARG VITE_OIDC_AUTHORITY ARG VITE_OIDC_CLIENT_ID +ARG ORGFRONT_URL ENV VITE_DEVFRONT_PUBLIC_URL=$VITE_DEVFRONT_PUBLIC_URL ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID +ENV ORGFRONT_URL=$ORGFRONT_URL RUN pnpm install --frozen-lockfile --ignore-scripts diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index cbd1d153..3ab2cd64 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -1178,14 +1178,14 @@ function ClientGeneralPage() { if (!trimmedJwksUri) { validationErrors.push( t( - "msg.dev.clients.general.public_key.validation.missing_jwks_uri", + "ui.dev.clients.general.public_key.validation.missing_jwks_uri", "JWKS URI를 입력해야 합니다.", ), ); } else if (!isValidUrl(trimmedJwksUri)) { validationErrors.push( t( - "msg.dev.clients.general.public_key.validation.invalid_jwks_uri", + "ui.dev.clients.general.public_key.validation.invalid_jwks_uri", "JWKS URI 형식이 올바르지 않습니다.", ), ); @@ -1193,7 +1193,7 @@ function ClientGeneralPage() { if (unsupportedParsedAlgorithms.length > 0) { validationErrors.push( t( - "msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", + "ui.dev.clients.general.public_key.validation.unsupported_parsed_algorithms", "JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}", { details: unsupportedParsedAlgorithmSummary }, ), @@ -1202,7 +1202,7 @@ function ClientGeneralPage() { if (missingParsedAlgorithms.length > 0) { validationErrors.push( t( - "msg.dev.clients.general.public_key.validation.missing_parsed_algorithms", + "ui.dev.clients.general.public_key.validation.missing_parsed_algorithms", "JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}", { details: missingParsedAlgorithmSummary }, ), @@ -2050,13 +2050,13 @@ function ClientGeneralPage() {

{t( "ui.dev.clients.general.scopes.picker_title", - "추가할 scope 선택", + "Add a scope", )}

{t( - "msg.dev.clients.general.scopes.picker_help", - "지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다.", + "ui.dev.clients.general.scopes.picker_help", + "Choose a supported scope or custom claim key to add it to the scope list.", )}

@@ -2430,7 +2430,7 @@ function ClientGeneralPage() { @@ -3069,8 +3069,8 @@ function ClientGeneralPage() {

{t( - "msg.dev.clients.general.security.headless_login_enable_help", - "Baron SSO 로그인 창 대신 RP 자체 로그인 UI를 사용하고, RP backend의 서명 키로 클라이언트를 검증하려는 경우 활성화합니다.", + "ui.dev.clients.general.security.headless_login_enable_help", + "Enable this when the RP uses its own login UI instead of the Baron SSO login page and the RP backend validates the client with a signing key.", )}

diff --git a/devfront/src/features/clients/components/TenantAccessPicker.tsx b/devfront/src/features/clients/components/TenantAccessPicker.tsx index 259bc938..e923d227 100644 --- a/devfront/src/features/clients/components/TenantAccessPicker.tsx +++ b/devfront/src/features/clients/components/TenantAccessPicker.tsx @@ -16,11 +16,7 @@ type TenantAccessPickerProps = { }; function resolveOrgFrontBaseUrl() { - return ( - import.meta.env.VITE_ORGFRONT_PUBLIC_URL || - import.meta.env.ORGFRONT_URL || - "http://localhost:5175" - ); + return import.meta.env.ORGFRONT_URL || "http://localhost:5175"; } export function TenantAccessPicker({ @@ -57,7 +53,7 @@ export function TenantAccessPicker({ aria-modal="true" aria-label={t( "ui.dev.clients.general.tenant_access.picker_title", - "테넌트 선택", + "Select tenant", )} >
@@ -66,13 +62,13 @@ export function TenantAccessPicker({

{t( "ui.dev.clients.general.tenant_access.picker_title", - "테넌트 선택", + "Select tenant", )}

{t( - "msg.dev.clients.general.tenant_access.picker_description", - "orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다.", + "ui.dev.clients.general.tenant_access.picker_description", + "Choose the tenants to allow from the orgfront org chart and add them to the list.", )}

@@ -83,7 +79,7 @@ export function TenantAccessPicker({ className="shrink-0" onClick={() => setPickerOpen(false)} > - {t("ui.common.close", "닫기")} + {t("ui.common.close", "Close")}
@@ -102,7 +98,7 @@ export function TenantAccessPicker({ variant="outline" onClick={() => setPickerOpen(false)} > - {t("ui.common.close", "닫기")} + {t("ui.common.close", "Close")}
@@ -123,7 +119,7 @@ export function TenantAccessPicker({ {t( "ui.dev.clients.general.tenant_access.open_picker", - "테넌트 선택기 열기", + "Open tenant picker", )} @@ -132,13 +128,13 @@ export function TenantAccessPicker({
{selectedCount > 0 ? t( - "msg.dev.clients.general.tenant_access.picker_hint_with_count", - "현재 {{count}}개가 선택되어 있습니다.", + "ui.dev.clients.general.tenant_access.picker_hint_with_count", + "{{count}} tenants selected.", { count: selectedCount }, ) : t( - "msg.dev.clients.general.tenant_access.picker_hint", - "선택기를 열어 허용 테넌트를 추가하세요.", + "ui.dev.clients.general.tenant_access.picker_hint", + "Open the picker to add allowed tenants.", )}
diff --git a/devfront/src/features/developer-access/developerAccessPages.test.ts b/devfront/src/features/developer-access/developerAccessPages.test.ts index 2415cf42..22df6dd5 100644 --- a/devfront/src/features/developer-access/developerAccessPages.test.ts +++ b/devfront/src/features/developer-access/developerAccessPages.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, it } from "vitest"; -import { normalizeDeveloperAccessPageSelection } from "./developerAccessPages"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + developerAccessPagesToLabel, + getDeveloperAccessPageLabel, + normalizeDeveloperAccessPageSelection, +} from "./developerAccessPages"; + +beforeEach(() => { + window.localStorage.clear(); + window.localStorage.setItem("locale", "ko"); +}); + +afterEach(() => { + window.localStorage.clear(); +}); describe("developer access pages", () => { it("collapses all non-all pages into all", () => { @@ -21,4 +34,18 @@ describe("developer access pages", () => { it("keeps explicit all selection", () => { expect(normalizeDeveloperAccessPageSelection(["all"])).toEqual(["all"]); }); + + it("returns localized labels for access pages", () => { + expect(getDeveloperAccessPageLabel("all")).toBe("전체"); + expect(developerAccessPagesToLabel(["overview", "audit"])).toBe( + "개요, 감사로그", + ); + + window.localStorage.setItem("locale", "en"); + + expect(getDeveloperAccessPageLabel("client_create")).toBe("Add linked app"); + expect(developerAccessPagesToLabel(["overview", "audit"])).toBe( + "Overview, Audit Logs", + ); + }); }); diff --git a/devfront/src/features/developer-access/developerAccessPages.ts b/devfront/src/features/developer-access/developerAccessPages.ts index ce7608f1..0e44187f 100644 --- a/devfront/src/features/developer-access/developerAccessPages.ts +++ b/devfront/src/features/developer-access/developerAccessPages.ts @@ -1,3 +1,5 @@ +import { t } from "../../lib/i18n"; + export type DeveloperAccessPage = | "all" | "overview" @@ -10,15 +12,40 @@ export const developerAccessPageOrder: DeveloperAccessPage[] = [ "audit", ]; -export const developerAccessPageOptions: Array<{ +export function getDeveloperAccessPageLabel(page: DeveloperAccessPage): string { + switch (page) { + case "all": + return t("ui.dev.access_pages.all", "전체"); + case "overview": + return t("ui.dev.access_pages.overview", "개요"); + case "client_create": + return t("ui.dev.access_pages.client_create", "연동 앱 추가"); + case "audit": + return t("ui.dev.access_pages.audit", "감사로그"); + default: + return page; + } +} + +export function getDeveloperAccessPageOptions(): Array<{ value: DeveloperAccessPage; label: string; -}> = [ - { value: "all", label: "전체" }, - { value: "overview", label: "개요" }, - { value: "client_create", label: "연동 앱 추가" }, - { value: "audit", label: "감사로그" }, -]; +}> { + return developerAccessPageOrder.length > 0 + ? [ + { value: "all", label: getDeveloperAccessPageLabel("all") }, + { + value: "overview", + label: getDeveloperAccessPageLabel("overview"), + }, + { + value: "client_create", + label: getDeveloperAccessPageLabel("client_create"), + }, + { value: "audit", label: getDeveloperAccessPageLabel("audit") }, + ] + : []; +} export function normalizeDeveloperAccessPages( pages: Array, @@ -61,20 +88,11 @@ export function normalizeDeveloperAccessPageSelection( export function developerAccessPagesToLabel(pages?: Array) { const normalized = normalizeDeveloperAccessPages(pages ?? []); if (normalized.length === 0 || normalized.includes("all")) { - return "전체"; + return getDeveloperAccessPageLabel("all"); } return normalized .map((page) => { - switch (page) { - case "overview": - return "개요"; - case "client_create": - return "연동 앱 추가"; - case "audit": - return "감사로그"; - default: - return page; - } + return getDeveloperAccessPageLabel(page); }) .join(", "); } diff --git a/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx index 8b967b86..79249fd8 100644 --- a/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx +++ b/devfront/src/features/developer-grants/DeveloperGrantsPage.tsx @@ -38,7 +38,8 @@ import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; import { type DeveloperAccessPage, - developerAccessPageOptions, + developerAccessPagesToLabel, + getDeveloperAccessPageOptions, normalizeDeveloperAccessPageSelection, normalizeDeveloperAccessPages, } from "../developer-access/developerAccessPages"; @@ -62,6 +63,7 @@ export default function DeveloperGrantsPage() { }); const profileRole = me?.role?.trim() || role; const isSuperAdmin = profileRole === "super_admin"; + const developerAccessPageOptions = getDeveloperAccessPageOptions(); const [userSearch, setUserSearch] = useState(""); const deferredUserSearch = useDeferredValue(userSearch.trim()); @@ -621,9 +623,7 @@ export default function DeveloperGrantsPage() { : ["all"] ).map((page) => ( - {developerAccessPageOptions.find( - (option) => option.value === page, - )?.label ?? page} + {developerAccessPagesToLabel([page])} ))} diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.tsx index 5f11a631..fee23d91 100644 --- a/devfront/src/features/developer-request/DeveloperRequestPage.tsx +++ b/devfront/src/features/developer-request/DeveloperRequestPage.tsx @@ -49,7 +49,8 @@ import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; import { type DeveloperAccessPage, - developerAccessPageOptions, + developerAccessPagesToLabel, + getDeveloperAccessPageOptions, normalizeDeveloperAccessPageSelection, normalizeDeveloperAccessPages, } from "../developer-access/developerAccessPages"; @@ -287,9 +288,7 @@ export default function DeveloperRequestPage() { req.accessPages, ).map((page) => ( - {developerAccessPageOptions.find( - (option) => option.value === page, - )?.label ?? page} + {developerAccessPagesToLabel([page])} )) ) : ( @@ -479,6 +478,7 @@ function RequestAccessModal({ const [accessPages, setAccessPages] = useState([ "all", ]); + const developerAccessPageOptions = getDeveloperAccessPageOptions(); const organizationDisplay = organization.trim() || t("ui.common.na", "없음"); useEffect(() => { diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts index b69c9a28..2dee876e 100644 --- a/devfront/src/features/overview/recentClientChanges.test.ts +++ b/devfront/src/features/overview/recentClientChanges.test.ts @@ -89,6 +89,127 @@ describe("recent client changes", () => { { label: "Relation", value: "admins" }, { label: "Subject", value: "User:1" }, ]); + + expect( + buildRecentClientChangeDetails("ADD_RELATION", { + relation: "config_editor", + subject: "User:2", + }), + ).toEqual([ + { label: "Relation", value: "config_editor" }, + { label: "Subject", value: "User:2" }, + ]); + }); + + it("ignores audit object key order changes in update details", () => { + mockLocale("ko"); + + expect( + buildRecentClientChangeDetails("UPDATE_CLIENT", { + before: { + id_token_claims: [ + { + key: "license", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "text", + writePermission: "admin_only", + }, + { + key: "date", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "date", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "date", + value: "", + valueType: "date", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }), + ).toEqual([]); + }); + + it("summarizes id_token_claims additions and removals", () => { + mockLocale("ko"); + + expect( + buildRecentClientChangeDetails("UPDATE_CLIENT", { + before: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "date", + value: "", + valueType: "date", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "test", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }), + ).toEqual([ + { + label: "커스텀 클레임", + value: "+ test (text), - date (date)", + }, + ]); }); it("builds recent client changes with sorting, filtering, and detail slicing", () => { @@ -192,6 +313,40 @@ describe("recent client changes", () => { after: { name: "Ignored" }, }, ), + makeAuditLog( + "evt-9", + "2026-05-27T15:00:00.000Z", + "UPDATE_CLIENT", + "client-a", + { + before: { + id_token_claims: [ + { + key: "license", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "text", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }, + ), ]; const changes = buildRecentClientChanges(auditLogs, clients); diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index 3e6614a0..b331346d 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -33,6 +33,27 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function normalizeAuditValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => normalizeAuditValue(item)); + } + + if (isRecord(value)) { + return Object.keys(value) + .sort() + .reduce>((acc, key) => { + acc[key] = normalizeAuditValue(value[key]); + return acc; + }, {}); + } + + return value; +} + +function auditValueSignature(value: unknown) { + return JSON.stringify(normalizeAuditValue(value)); +} + export function getRecentClientActionLabel(action: string) { switch (action) { case "CREATE_CLIENT": @@ -74,17 +95,174 @@ function getRecentClientFieldLabel(key: string) { "ui.dev.clients.details.credentials.client_secret", "클라이언트 시크릿", ); + case "id_token_claims": + return t("ui.dev.clients.general.id_token_claims.title", "Custom Claims"); default: return key; } } +function getIdTokenClaimIdentity(claim: Record) { + const namespace = + typeof claim.namespace === "string" && claim.namespace + ? claim.namespace + : null; + const key = typeof claim.key === "string" && claim.key ? claim.key : null; + + if (!namespace || !key) { + return null; + } + + return { namespace, key }; +} + +function formatIdTokenClaimDisplayName(claim: Record) { + const identity = getIdTokenClaimIdentity(claim); + if (!identity) { + return "unknown"; + } + if (identity.namespace === "rp_claims") { + return identity.key; + } + return `${identity.namespace}:${identity.key}`; +} + +function isSimpleAuditScalar(value: unknown) { + return ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +function formatIdTokenClaimChangeSummary( + beforeValue: unknown, + afterValue: unknown, +) { + if (!isRecord(beforeValue) || !isRecord(afterValue)) { + return null; + } + + const beforeDisplayName = formatIdTokenClaimDisplayName(beforeValue); + const afterDisplayName = formatIdTokenClaimDisplayName(afterValue); + if (beforeDisplayName !== afterDisplayName) { + return `~ ${beforeDisplayName} → ${afterDisplayName}`; + } + + const beforeValueType = + typeof beforeValue.valueType === "string" ? beforeValue.valueType : null; + const afterValueType = + typeof afterValue.valueType === "string" ? afterValue.valueType : null; + + if (beforeValueType && afterValueType && beforeValueType !== afterValueType) { + return `~ ${beforeDisplayName}: ${beforeValueType} → ${afterValueType}`; + } + + const beforeScalar = beforeValue.value; + const afterScalar = afterValue.value; + if ( + isSimpleAuditScalar(beforeScalar) && + isSimpleAuditScalar(afterScalar) && + formatAuditValue(beforeScalar) !== formatAuditValue(afterScalar) + ) { + return `~ ${beforeDisplayName}: ${formatAuditValue(beforeScalar)} → ${formatAuditValue(afterScalar)}`; + } + + return `~ ${beforeDisplayName}`; +} + +function summarizeIdTokenClaimArrayChange( + beforeValue: unknown, + afterValue: unknown, +) { + if (!Array.isArray(beforeValue) || !Array.isArray(afterValue)) { + return null; + } + + const beforeClaims = beforeValue.filter(isRecord); + const afterClaims = afterValue.filter(isRecord); + const beforeByIdentity = new Map>(); + const afterByIdentity = new Map>(); + + for (const claim of beforeClaims) { + const identity = getIdTokenClaimIdentity(claim); + if (identity) { + beforeByIdentity.set(`${identity.namespace}:${identity.key}`, claim); + } + } + + for (const claim of afterClaims) { + const identity = getIdTokenClaimIdentity(claim); + if (identity) { + afterByIdentity.set(`${identity.namespace}:${identity.key}`, claim); + } + } + + const additions: string[] = []; + const removals: string[] = []; + const updates: string[] = []; + + for (const [identity, afterClaim] of afterByIdentity.entries()) { + const beforeClaim = beforeByIdentity.get(identity); + const displayName = formatIdTokenClaimDisplayName(afterClaim); + + if (!beforeClaim) { + const valueType = + typeof afterClaim.valueType === "string" ? afterClaim.valueType : null; + additions.push( + valueType ? `+ ${displayName} (${valueType})` : `+ ${displayName}`, + ); + continue; + } + + if (auditValueSignature(beforeClaim) === auditValueSignature(afterClaim)) { + continue; + } + + const summary = formatIdTokenClaimChangeSummary(beforeClaim, afterClaim); + if (summary) { + updates.push(summary); + } + } + + for (const [identity, beforeClaim] of beforeByIdentity.entries()) { + if (afterByIdentity.has(identity)) { + continue; + } + const displayName = formatIdTokenClaimDisplayName(beforeClaim); + const valueType = + typeof beforeClaim.valueType === "string" ? beforeClaim.valueType : null; + removals.push( + valueType ? `- ${displayName} (${valueType})` : `- ${displayName}`, + ); + } + + const parts = [...additions, ...removals, ...updates].slice(0, 4); + if (parts.length === 0) { + return null; + } + + if (additions.length + removals.length + updates.length > parts.length) { + parts.push("..."); + } + + return parts.join(", "); +} + export function buildRecentClientChangeDetails( action: string, details: AuditDetails, ) { const before = isRecord(details.before) ? details.before : {}; const after = isRecord(details.after) ? details.after : {}; + const sourceDetails = + action === "ADD_RELATION" + ? { ...after, ...details } + : action === "REMOVE_RELATION" + ? { ...before, ...details } + : {}; if (action === "ROTATE_SECRET") { return [ @@ -96,21 +274,23 @@ export function buildRecentClientChangeDetails( } if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { - const source = action === "ADD_RELATION" ? after : before; + const source = sourceDetails as Record; + const relation = source.relation; + const subject = source.subject; return [ - ...(source.relation + ...(typeof relation === "string" && relation ? [ { label: getRecentClientFieldLabel("relation"), - value: formatAuditValue(source.relation), + value: formatAuditValue(relation), }, ] : []), - ...(source.subject + ...(typeof subject === "string" && subject ? [ { label: getRecentClientFieldLabel("subject"), - value: formatAuditValue(source.subject), + value: formatAuditValue(subject), }, ] : []), @@ -126,8 +306,21 @@ export function buildRecentClientChangeDetails( const beforeValue = before[key]; const afterValue = after[key]; + if (key === "id_token_claims") { + const value = summarizeIdTokenClaimArrayChange(beforeValue, afterValue); + if (!value) { + return null; + } + return { + label: getRecentClientFieldLabel(key), + value, + }; + } + if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") { - if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) { + if ( + auditValueSignature(beforeValue) === auditValueSignature(afterValue) + ) { return null; } } @@ -161,6 +354,10 @@ export function buildRecentClientChangeDetails( }) .filter((item): item is { label: string; value: string } => Boolean(item)); + if (changes.length === 0) { + return []; + } + return changes.slice(0, 3); } @@ -194,7 +391,12 @@ export function buildRecentClientChanges( detailLabels: buildRecentClientChangeDetails(action, details), } satisfies RecentClientChange; }) - .filter((item): item is RecentClientChange => Boolean(item)) + .filter((item): item is RecentClientChange => { + if (!item) { + return false; + } + return item.detailLabels.length > 0; + }) .sort( (left, right) => new Date(right.timestamp).getTime() - diff --git a/devfront/src/lib/i18n.test.ts b/devfront/src/lib/i18n.test.ts new file mode 100644 index 00000000..b44d349c --- /dev/null +++ b/devfront/src/lib/i18n.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { t } from "./i18n"; + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("i18n", () => { + it("returns English copy for the developer request and grants screens", () => { + window.localStorage.setItem("locale", "en"); + + expect(t("ui.dev.request.list.title", "신청 내역")).toBe("Request History"); + expect( + t( + "msg.dev.request.list.approved_count", + "총 {{count}}명의 사용자가 승인되었습니다.", + { count: 0 }, + ), + ).toBe("0 users have been approved."); + expect(t("ui.dev.grants.form.title", "직접 부여")).toBe("Direct Grant"); + expect( + t( + "msg.dev.grants.form.description", + "사용자를 선택하면 현재 소속 정보가 표시되고, 그 사용자에게 개발자 권한을 즉시 부여합니다.", + ), + ).toBe( + "Select a user to view their current tenant, email, and phone, then grant developer access immediately.", + ); + expect( + t( + "msg.dev.grants.list.description", + "현재 부여된 개발자 권한 목록입니다.", + ), + ).toBe("Current developer access grants."); + }); +}); diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index d9df8136..60ce8c2c 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -321,12 +321,14 @@ admin_desc = "Manage developer access requests submitted by users." approved = "Approved." cancelled = "Approval has been cancelled." empty = "No requests found." -list.approved_count = "{{count}} users have been approved." need_cancel_notes = "Please enter a reason for cancelling approval." need_notes = "Please enter a rejection reason." rejected = "Rejected." user_desc = "Review your request history and submit a new access request." +[msg.dev.request.list] +approved_count = "{{count}} users have been approved." + [msg.dev.request.modal] desc = "Please enter the reason for your request. It will be approved after administrator review." tenant_required = "Please submit a developer access request." @@ -573,10 +575,8 @@ admin_notes_placeholder = "e.g. Grant access after verifying the test environmen empty = "There are no granted permissions." forbidden = "Only super admin can directly grant developer access." forbidden_desc = "This screen is available only to super admin." -form.description = "Select a user to view their current tenant, email, and phone, then grant developer access immediately." selected_info_description = "Review the selected user's tenant, email, and phone." user_section_description = "Enter a search term to select a user. The next-step information stays empty until a user is chosen." -list.description = "Current developer access grants." load_error = "Failed to load developer access grants." reason = "Grant reason" revoke = "Revoke" @@ -588,6 +588,13 @@ tenant_required = "The selected user's tenant information is unavailable." tenant_missing = "No tenant information is available for the selected user." user_required = "Select a user before granting access." phone_missing = "No phone number is registered." +pages_hint = "If you select All, Overview, Add linked app, and Audit Logs are all included." + +[msg.dev.grants.form] +description = "Select a user to view their current tenant, email, and phone, then grant developer access immediately." + +[msg.dev.grants.list] +description = "Current developer access grants." [msg.dev.dashboard.notice] consent_audit = "Consent Audit" @@ -1351,10 +1358,8 @@ admin_notes = "Grant Reason" all_tenants = "All Tenants" approved = "Approved" date = "Granted At" -form.title = "Direct Grant" grant = "Grant Directly" input_section = "Input" -list.title = "Granted Access" pages = "Access Pages" read_only = "Read Only" reason = "Grant Reason" @@ -1369,6 +1374,12 @@ user_section = "User Selection" user = "User" user_search_placeholder = "Search by name or email..." +[ui.dev.grants.form] +title = "Direct Grant" + +[ui.dev.grants.list] +title = "Granted Access" + [ui.dev.request.modal] email = "Email" name = "Name" @@ -1380,6 +1391,12 @@ reason_placeholder = "e.g. I need to create an OIDC client for internal service role = "Role" title = "Developer Access Request" +[ui.dev.access_pages] +all = "All" +overview = "Overview" +client_create = "Add linked app" +audit = "Audit Logs" + [ui.dev.request.status] approved = "Approved" cancelled = "Approval Cancelled" @@ -1597,6 +1614,8 @@ name_placeholder = "e.g. profile" title = "Scopes" offline_access_title = "offline_access scope is required when using refresh tokens." offline_access_toggle = "Show details" +picker_title = "Select a scope to add" +picker_help = "Choose a supported scope or custom claim key to add it to the scope list." [ui.dev.clients.general.scopes.table] description = "Scope Description" @@ -1617,6 +1636,12 @@ empty = "No tenants match your search." hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant." autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list." validation_required = "Select at least one allowed tenant when tenant access restriction is enabled." +picker_title = "Select tenant" +picker_label = "Add allowed tenant" +open_picker = "Open tenant picker" +picker_description = "Choose the tenants to allow from the orgfront org chart and add them to the list." +picker_hint = "Open the picker to add allowed tenants." +picker_hint_with_count = "{{count}} tenants selected." [ui.dev.clients.general.id_token_claims] title = "Custom Claims" @@ -1652,7 +1677,7 @@ pkce = "PKCE" headless_login = "Headless Login" title = "Security Settings" headless_login_enable = "Headless Login (Custom Login UI)" -headless_login_enable_help = "Enable this when the RP uses its own login UI and the RP backend proves the client with signed keys instead of the Baron SSO login page." +headless_login_enable_help = "Enable this when the RP uses its own login UI instead of the Baron SSO login page and the RP backend validates the client with a signing key." [ui.dev.clients.general.public_key] auth_method = "Token Endpoint Auth Method" @@ -1683,6 +1708,12 @@ cache_status = "Status" cache_uri = "JWKS URI" revoke_cache = "Revoke Cache" +[ui.dev.clients.general.public_key.validation] +missing_jwks_uri = "Enter a JWKS URI." +invalid_jwks_uri = "JWKS URI format is invalid." +unsupported_parsed_algorithms = "The JWKS contains unsupported algorithms: {{details}}" +missing_parsed_algorithms = "The JWKS contains keys without an `alg` declaration: {{details}}" + [ui.dev.clients.relationships] title = "Client Relationships" add_title = "Add Relationship" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index bad71c01..d175aa63 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -321,12 +321,14 @@ admin_desc = "사용자들의 개발자 권한 신청 내역을 관리합니다. approved = "승인되었습니다." cancelled = "승인이 취소되었습니다." empty = "신청 내역이 없습니다." -list.approved_count = "총 {{count}}명의 사용자가 승인되었습니다." need_cancel_notes = "승인 취소 사유를 입력해주세요." need_notes = "반려 사유를 입력해주세요." rejected = "반려되었습니다." user_desc = "내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다." +[msg.dev.request.list] +approved_count = "총 {{count}}명의 사용자가 승인되었습니다." + [msg.dev.request.modal] desc = "신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다." tenant_required = "개발자 권한 신청을 진행해 주세요." @@ -573,10 +575,8 @@ admin_notes_placeholder = "예: 테스트 환경 확인 후 권한 부여" empty = "부여된 권한이 없습니다." forbidden = "개발자 권한 직접 부여는 super admin만 사용할 수 있습니다." forbidden_desc = "이 화면은 super admin만 사용할 수 있습니다." -form.description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다." selected_info_description = "선택된 사용자의 소속, 이메일, 전화번호를 확인합니다." user_section_description = "검색어를 입력해 사용자를 선택합니다. 선택 전에는 다음 단계 정보가 비어 있습니다." -list.description = "현재 부여된 개발자 권한 목록입니다." load_error = "개발자 권한 목록을 불러오지 못했습니다." reason = "부여 사유" revoke = "회수" @@ -588,6 +588,13 @@ tenant_required = "선택한 사용자의 테넌트 정보를 확인할 수 없 tenant_missing = "선택한 사용자의 테넌트 정보를 확인할 수 없습니다." user_required = "부여할 사용자를 선택해주세요." phone_missing = "등록된 전화번호가 없습니다." +pages_hint = "전체를 선택하면 개요, 연동 앱 추가, 감사로그가 모두 포함됩니다." + +[msg.dev.grants.form] +description = "사용자를 선택하면 현재 소속 테넌트, 이메일, 전화번호를 확인한 뒤 개발자 권한을 즉시 부여합니다." + +[msg.dev.grants.list] +description = "현재 부여된 개발자 권한 목록입니다." [msg.dev.dashboard.notice] consent_audit = "Consent 회수는 감사 로그와 연계" @@ -1351,10 +1358,8 @@ admin_notes = "부여 사유" all_tenants = "전체 테넌트" approved = "승인됨" date = "부여일" -form.title = "직접 부여" grant = "직접 부여" input_section = "입력" -list.title = "부여된 권한" pages = "권한 페이지" read_only = "읽기 전용" reason = "부여 사유" @@ -1369,6 +1374,12 @@ user_section = "사용자 선택" user = "사용자" user_search_placeholder = "이름 또는 이메일 검색..." +[ui.dev.grants.form] +title = "직접 부여" + +[ui.dev.grants.list] +title = "부여된 권한" + [ui.dev.request.modal] email = "이메일" name = "성함" @@ -1380,6 +1391,12 @@ reason_placeholder = "예: 자체 서비스 연동 및 테스트용 OIDC 클라 role = "역할" title = "개발자 등록 신청" +[ui.dev.access_pages] +all = "전체" +overview = "개요" +client_create = "연동 앱 추가" +audit = "감사로그" + [ui.dev.request.status] approved = "승인됨" cancelled = "승인 취소됨" @@ -1596,6 +1613,8 @@ name_placeholder = "e.g. profile" title = "스코프" offline_access_title = "Refresh token 사용 시 offline_access scope가 필요합니다." offline_access_toggle = "상세 안내 보기" +picker_title = "추가할 scope 선택" +picker_help = "지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다." [ui.dev.clients.general.scopes.table] description = "설명" @@ -1616,6 +1635,12 @@ empty = "검색 결과가 없습니다." hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다." autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다." validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다." +picker_title = "테넌트 선택" +picker_label = "허용 테넌트 추가" +open_picker = "테넌트 선택기 열기" +picker_description = "orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다." +picker_hint = "선택기를 열어 허용 테넌트를 추가하세요." +picker_hint_with_count = "현재 {{count}}개가 선택되어 있습니다." [ui.dev.clients.general.id_token_claims] title = "커스텀 클레임" @@ -1682,6 +1707,12 @@ cache_status = "상태" cache_uri = "JWKS URI" revoke_cache = "캐시 삭제" +[ui.dev.clients.general.public_key.validation] +missing_jwks_uri = "JWKS URI를 입력해야 합니다." +invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다." +unsupported_parsed_algorithms = "JWKS에 지원하지 않는 알고리즘이 있습니다: {{details}}" +missing_parsed_algorithms = "JWKS에 알고리즘(`alg`)이 선언되지 않은 키가 있습니다: {{details}}" + [ui.dev.clients.relationships] title = "클라이언트 관계" add_title = "관계 추가" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 1a84e467..45f2d56f 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -335,16 +335,19 @@ admin_desc = "" approved = "" cancelled = "" empty = "" -list.approved_count = "" need_cancel_notes = "" need_notes = "" rejected = "" user_desc = "" +[msg.dev.request.list] +approved_count = "" + [msg.dev.request.modal] desc = "" tenant_required = "" tenant_required_detail = "" +pages_hint = "" [msg.dev.request.status] approved = "" @@ -610,14 +613,13 @@ admin_notes_placeholder = "" empty = "" forbidden = "" forbidden_desc = "" -form.description = "" selected_info_description = "" user_section_description = "" -list.description = "" load_error = "" reason = "" revoke = "" revoke_success = "" +pages_hint = "" search_empty = "" search_loading = "" selected_user = "" @@ -627,6 +629,12 @@ user_required = "" phone_missing = "" required = "" +[msg.dev.grants.form] +description = "" + +[msg.dev.grants.list] +description = "" + [msg.dev.dashboard.notice] consent_audit = "" dev_scope = "" @@ -1403,10 +1411,8 @@ admin_notes = "" all_tenants = "" approved = "" date = "" -form.title = "" grant = "" input_section = "" -list.title = "" read_only = "" reason = "" reason_placeholder = "" @@ -1420,6 +1426,12 @@ user_section = "" user = "" user_search_placeholder = "" +[ui.dev.grants.form] +title = "" + +[ui.dev.grants.list] +title = "" + [ui.dev.request.modal] email = "" name = "" @@ -1430,6 +1442,12 @@ reason_placeholder = "" role = "" title = "" +[ui.dev.access_pages] +all = "" +overview = "" +client_create = "" +audit = "" + [ui.dev.request.status] approved = "" cancelled = "" @@ -1645,6 +1663,8 @@ name_placeholder = "" title = "" offline_access_title = "" offline_access_toggle = "" +picker_title = "" +picker_help = "" [ui.dev.clients.general.scopes.table] description = "" @@ -1665,6 +1685,12 @@ empty = "" hint = "" autocomplete_hint = "" validation_required = "" +picker_title = "" +picker_label = "" +open_picker = "" +picker_description = "" +picker_hint = "" +picker_hint_with_count = "" [ui.dev.clients.general.id_token_claims] title = "" @@ -1730,6 +1756,12 @@ cache_status = "" cache_uri = "" revoke_cache = "" +[ui.dev.clients.general.public_key.validation] +missing_jwks_uri = "" +invalid_jwks_uri = "" +unsupported_parsed_algorithms = "" +missing_parsed_algorithms = "" + [ui.dev.clients.relationships] title = "" add_title = "" diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts index ae610f9a..d4c89085 100644 --- a/devfront/vite.config.ts +++ b/devfront/vite.config.ts @@ -17,6 +17,7 @@ const allowedHosts = getAllowedHosts( export default defineConfig( mergeConfig(commonViteConfig, { + envPrefix: ["VITE_", "ORGFRONT_"], cacheDir: process.env.DEVFRONT_VITE_CACHE_DIR ?? "/tmp/baron-sso-devfront-vite-cache", diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index 31ca94e2..977aa841 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -455,6 +455,7 @@ services: VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL:-} VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-} VITE_OIDC_CLIENT_ID: devfront + ORGFRONT_URL: ${ORGFRONT_URL:-} container_name: baron_devfront restart: unless-stopped env_file: diff --git a/locales/en.toml b/locales/en.toml index 99387a15..9abc4ba9 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -2687,6 +2687,12 @@ title = "Direct Grant" [ui.dev.grants.list] title = "Granted Access" +[ui.dev.access_pages] +all = "All" +overview = "Overview" +client_create = "Add linked app" +audit = "Audit Logs" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/locales/ko.toml b/locales/ko.toml index fce1b247..7e1be078 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -2687,6 +2687,12 @@ title = "직접 부여" [ui.dev.grants.list] title = "부여된 권한" +[ui.dev.access_pages] +all = "전체" +overview = "개요" +client_create = "연동 앱 추가" +audit = "감사로그" + [ui.dev.header] plane = "Dev Plane" subtitle = "Manage your applications" diff --git a/locales/template.toml b/locales/template.toml index 5a508530..978d1ead 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -2687,6 +2687,12 @@ title = "" [ui.dev.grants.list] title = "" +[ui.dev.access_pages] +all = "" +overview = "" +client_create = "" +audit = "" + [ui.dev.header] plane = "" subtitle = ""