forked from baron/baron-sso
custom claim 권한체크 확인
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 802 KiB After Width: | Height: | Size: 805 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 810 KiB After Width: | Height: | Size: 815 KiB |
@@ -37,6 +37,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
fetchClient,
|
||||
fetchConsents,
|
||||
@@ -154,7 +155,7 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) {
|
||||
for (const row of rows) {
|
||||
const key = row.key.trim();
|
||||
if (!key) continue;
|
||||
metadata[key] = row.value.trim();
|
||||
metadata[key] = draftRowValueToMetadataValue(row);
|
||||
metadata[`${key}_permissions`] = {
|
||||
readPermission: row.readPermission,
|
||||
writePermission: row.writePermission,
|
||||
@@ -163,6 +164,38 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function draftRowValueToMetadataValue(row: MetadataDraftRow) {
|
||||
const value = row.value.trim();
|
||||
switch (row.valueType) {
|
||||
case "number": {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
case "boolean":
|
||||
return value === "true";
|
||||
case "array":
|
||||
if (value === "") return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
case "object":
|
||||
if (value === "") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function isRPClaimValueType(value: string): value is RPClaimValueType {
|
||||
return (
|
||||
value === "text" ||
|
||||
@@ -994,20 +1027,54 @@ function ClientConsentsPage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
type={rpClaimInputType(row.valueType)}
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.rp_claims.value_placeholder",
|
||||
"claim value",
|
||||
)}
|
||||
/>
|
||||
{row.valueType === "boolean" ? (
|
||||
<select
|
||||
value={row.value === "false" ? "false" : "true"}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
aria-label={`${row.key} boolean`}
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : row.valueType === "array" ||
|
||||
row.valueType === "object" ? (
|
||||
<Textarea
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
className="min-h-10 font-mono text-xs"
|
||||
placeholder={
|
||||
row.valueType === "array"
|
||||
? `["value"]`
|
||||
: `{"key": "value"}`
|
||||
}
|
||||
aria-label={`${row.key} ${row.valueType}`}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={rpClaimInputType(row.valueType)}
|
||||
value={row.value}
|
||||
onChange={(event) =>
|
||||
updateMetadataDraftRow(row.id, {
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.consents.rp_claims.value_placeholder",
|
||||
"claim value",
|
||||
)}
|
||||
aria-label={`${row.key} ${row.valueType}`}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
value={row.readPermission}
|
||||
onChange={(event) =>
|
||||
|
||||
232
devfront/src/features/clients/ClientGeneralPage.claims.test.tsx
Normal file
232
devfront/src/features/clients/ClientGeneralPage.claims.test.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClientDetailResponse } from "../../lib/devApi";
|
||||
import ClientGeneralPage from "./ClientGeneralPage";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const fetchClientMock = vi.fn();
|
||||
const updateClientMock = vi.fn();
|
||||
const fetchClientRelationsMock = vi.fn();
|
||||
const fetchMyTenantsMock = vi.fn();
|
||||
const fetchMeMock = vi.fn();
|
||||
|
||||
let authState = {
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
role: "super_admin",
|
||||
name: "Dev Admin",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-router-dom")>(
|
||||
"react-router-dom",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/devApi", () => ({
|
||||
createClient: vi.fn(),
|
||||
deleteClient: vi.fn(),
|
||||
fetchClient: (...args: unknown[]) => fetchClientMock(...args),
|
||||
fetchClientRelations: (...args: unknown[]) =>
|
||||
fetchClientRelationsMock(...args),
|
||||
fetchMyTenants: (...args: unknown[]) => fetchMyTenantsMock(...args),
|
||||
refreshHeadlessJwksCache: vi.fn(),
|
||||
revokeHeadlessJwksCache: vi.fn(),
|
||||
updateClient: (...args: unknown[]) => updateClientMock(...args),
|
||||
updateClientStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../auth/authApi", () => ({
|
||||
fetchMe: (...args: unknown[]) => fetchMeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/i18n", () => ({
|
||||
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}));
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
function makeClientDetail(claimKey: string): ClientDetailResponse {
|
||||
return {
|
||||
client: {
|
||||
id: "client-claims",
|
||||
name: "Claims App",
|
||||
type: "private",
|
||||
status: "active",
|
||||
redirectUris: ["https://rp.example.com/callback"],
|
||||
scopes: ["openid", "profile"],
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
metadata: {
|
||||
description: "Claims app",
|
||||
structured_scopes: [
|
||||
{
|
||||
id: "1",
|
||||
name: "openid",
|
||||
description: "",
|
||||
mandatory: true,
|
||||
},
|
||||
],
|
||||
id_token_claims: [
|
||||
{
|
||||
namespace: "rp_claims",
|
||||
key: claimKey,
|
||||
value: "A",
|
||||
valueType: "text",
|
||||
readPermission: "admin_only",
|
||||
writePermission: "admin_only",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
endpoints: {
|
||||
discovery: "https://issuer/.well-known/openid-configuration",
|
||||
issuer: "https://issuer",
|
||||
authorization: "https://issuer/oauth2/auth",
|
||||
token: "https://issuer/oauth2/token",
|
||||
userinfo: "https://issuer/userinfo",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
);
|
||||
descriptor?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/clients/client-claims/settings"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/clients/:id/settings"
|
||||
element={<ClientGeneralPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
|
||||
return { container, queryClient };
|
||||
}
|
||||
|
||||
describe("ClientGeneralPage RP claims", () => {
|
||||
beforeEach(() => {
|
||||
authState = {
|
||||
user: {
|
||||
access_token: "access-token",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
role: "super_admin",
|
||||
name: "Dev Admin",
|
||||
},
|
||||
},
|
||||
};
|
||||
fetchClientMock.mockResolvedValue(makeClientDetail("old_claim"));
|
||||
updateClientMock.mockResolvedValue(makeClientDetail("new_claim"));
|
||||
fetchClientRelationsMock.mockResolvedValue({ items: [] });
|
||||
fetchMyTenantsMock.mockResolvedValue([]);
|
||||
fetchMeMock.mockResolvedValue({
|
||||
id: "admin-user",
|
||||
role: "super_admin",
|
||||
name: "Dev Admin",
|
||||
});
|
||||
navigateMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("updates the client detail cache with saved RP claims before stale data can rehydrate the form", async () => {
|
||||
const { container, queryClient } = await renderPage();
|
||||
|
||||
const claimKeyInput = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="e.g. locale"]',
|
||||
);
|
||||
expect(claimKeyInput).not.toBeNull();
|
||||
expect(claimKeyInput?.value).toBe("old_claim");
|
||||
|
||||
await setInputValue(claimKeyInput as HTMLInputElement, "new_claim");
|
||||
|
||||
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) =>
|
||||
button.textContent?.includes("저장") ||
|
||||
button.textContent?.includes("Save"),
|
||||
);
|
||||
expect(saveButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const cached = queryClient.getQueryData<ClientDetailResponse>([
|
||||
"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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.read_permission",
|
||||
"ui.dev.clients.general.id_token_claims.table.nullable",
|
||||
"Nullable",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.read_user_allowed",
|
||||
"Read",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.write_permission",
|
||||
"ui.dev.clients.general.id_token_claims.table.write_user_allowed",
|
||||
"Write",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.id_token_claims.table.value",
|
||||
"Value",
|
||||
"ui.dev.clients.general.id_token_claims.table.default_value",
|
||||
"Default Value",
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-bold">
|
||||
@@ -2227,66 +2268,65 @@ function ClientGeneralPage() {
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<select
|
||||
value={claim.readPermission}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"readPermission",
|
||||
e.target.value as CustomClaimPermission,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.read_permission_label",
|
||||
"읽기 권한",
|
||||
)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<option value="admin_only">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
checked={claim.nullable}
|
||||
onCheckedChange={(checked) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"nullable",
|
||||
checked,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.nullable_label",
|
||||
"Nullable",
|
||||
)}
|
||||
</option>
|
||||
<option value="user_and_admin">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<select
|
||||
value={claim.writePermission}
|
||||
onChange={(e) =>
|
||||
updateIdTokenClaim(
|
||||
claim.id,
|
||||
"writePermission",
|
||||
e.target.value as CustomClaimPermission,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.write_permission_label",
|
||||
"쓰기 권한",
|
||||
)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<option value="admin_only">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.admin_only",
|
||||
"관리자만 가능",
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
checked={
|
||||
claim.readPermission === "user_and_admin"
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
setIdTokenClaimPermissionAllowed(
|
||||
claim.id,
|
||||
"readPermission",
|
||||
checked,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.read_user_allowed_label",
|
||||
"Read 사용자 허용",
|
||||
)}
|
||||
</option>
|
||||
<option value="user_and_admin">
|
||||
{t(
|
||||
"ui.common.custom_claim_permission.user_and_admin",
|
||||
"사용자 및 관리자 가능",
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
checked={
|
||||
claim.writePermission === "user_and_admin"
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
setIdTokenClaimPermissionAllowed(
|
||||
claim.id,
|
||||
"writePermission",
|
||||
checked,
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.id_token_claims.write_user_allowed_label",
|
||||
"Write 사용자 허용",
|
||||
)}
|
||||
</option>
|
||||
</select>
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<Input
|
||||
@@ -2301,7 +2341,7 @@ function ClientGeneralPage() {
|
||||
className="h-9 font-mono text-xs"
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.id_token_claims.value_placeholder",
|
||||
"Enter the claim value",
|
||||
"Enter the default value",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span aria-hidden="true">
|
||||
{t(
|
||||
"ui.dev.clients.tenant_scoped",
|
||||
"Tenant-scoped",
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
{isClientTenantLimited(client) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span aria-hidden="true">
|
||||
{t(
|
||||
"ui.dev.clients.tenant_limited",
|
||||
"Tenant-limited",
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
@@ -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(<ClientConsentsPage />, {
|
||||
path: "/clients/:id/consents",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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, {
|
||||
|
||||
63
devfront/tests/devfront-client-claims-cache.spec.ts
Normal file
63
devfront/tests/devfront-client-claims-cache.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
93
devfront/tests/helpers/static-devfront.ts
Normal file
93
devfront/tests/helpers/static-devfront.ts
Normal file
@@ -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<string, string> = {
|
||||
".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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user