1
0
forked from baron/baron-sso

Merge pull request 'feature/df-ui-locale' (#1178) from feature/df-ui-locale into dev

Reviewed-on: baron/baron-sso#1178
This commit is contained in:
2026-06-16 16:10:58 +09:00
19 changed files with 628 additions and 77 deletions

View File

@@ -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=

View File

@@ -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

View File

@@ -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() {
<p className="text-sm font-semibold">
{t(
"ui.dev.clients.general.scopes.picker_title",
"추가할 scope 선택",
"Add a scope",
)}
</p>
<p className="text-xs text-muted-foreground">
{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.",
)}
</p>
</div>
@@ -2430,7 +2430,7 @@ function ClientGeneralPage() {
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.picker_label",
"허용 테넌트 추가",
"Add allowed tenant",
)}{" "}
<span className="text-destructive">*</span>
</Label>
@@ -3069,8 +3069,8 @@ function ClientGeneralPage() {
</Label>
<p className="text-[10px] text-muted-foreground">
{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.",
)}
</p>
</div>

View File

@@ -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",
)}
>
<div className="flex h-[92vh] w-[min(96vw,1200px)] flex-col overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-2xl">
@@ -66,13 +62,13 @@ export function TenantAccessPicker({
<h2 className="text-lg font-semibold">
{t(
"ui.dev.clients.general.tenant_access.picker_title",
"테넌트 선택",
"Select tenant",
)}
</h2>
<p className="text-sm text-muted-foreground">
{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.",
)}
</p>
</div>
@@ -83,7 +79,7 @@ export function TenantAccessPicker({
className="shrink-0"
onClick={() => setPickerOpen(false)}
>
{t("ui.common.close", "닫기")}
{t("ui.common.close", "Close")}
</Button>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-md border">
@@ -102,7 +98,7 @@ export function TenantAccessPicker({
variant="outline"
onClick={() => setPickerOpen(false)}
>
{t("ui.common.close", "닫기")}
{t("ui.common.close", "Close")}
</Button>
</div>
</div>
@@ -123,7 +119,7 @@ export function TenantAccessPicker({
<Building2 className="h-4 w-4" />
{t(
"ui.dev.clients.general.tenant_access.open_picker",
"테넌트 선택기 열기",
"Open tenant picker",
)}
</Button>
@@ -132,13 +128,13 @@ export function TenantAccessPicker({
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
{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.",
)}
</div>
</div>

View File

@@ -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",
);
});
});

View File

@@ -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<string | undefined | null>,
@@ -61,20 +88,11 @@ export function normalizeDeveloperAccessPageSelection(
export function developerAccessPagesToLabel(pages?: Array<string | null>) {
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(", ");
}

View File

@@ -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) => (
<Badge key={page} variant="outline">
{developerAccessPageOptions.find(
(option) => option.value === page,
)?.label ?? page}
{developerAccessPagesToLabel([page])}
</Badge>
))}
</div>

View File

@@ -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) => (
<Badge key={page} variant="outline">
{developerAccessPageOptions.find(
(option) => option.value === page,
)?.label ?? page}
{developerAccessPagesToLabel([page])}
</Badge>
))
) : (
@@ -479,6 +478,7 @@ function RequestAccessModal({
const [accessPages, setAccessPages] = useState<DeveloperAccessPage[]>([
"all",
]);
const developerAccessPageOptions = getDeveloperAccessPageOptions();
const organizationDisplay = organization.trim() || t("ui.common.na", "없음");
useEffect(() => {

View File

@@ -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);

View File

@@ -33,6 +33,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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<Record<string, unknown>>((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<string, unknown>) {
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<string, unknown>) {
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<string, Record<string, unknown>>();
const afterByIdentity = new Map<string, Record<string, unknown>>();
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<string, unknown>;
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() -

View File

@@ -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.");
});
});

View File

@@ -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"

View File

@@ -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 = "관계 추가"

View File

@@ -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 = ""

View File

@@ -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",

View File

@@ -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:

View File

@@ -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"

View File

@@ -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"

View File

@@ -2687,6 +2687,12 @@ title = ""
[ui.dev.grants.list]
title = ""
[ui.dev.access_pages]
all = ""
overview = ""
client_create = ""
audit = ""
[ui.dev.header]
plane = ""
subtitle = ""