1
0
forked from baron/baron-sso

offline_access 제거 확인 추가 및 scope 선택 개선

This commit is contained in:
2026-06-11 15:02:52 +09:00
parent c495e9119b
commit 22afe6654e
3 changed files with 303 additions and 5 deletions

View File

@@ -315,6 +315,42 @@ describe("ClientGeneralPage RP claims", () => {
);
});
it("shows supported scopes and custom claims without integrated offline_access from the add scope button", async () => {
const { container } = await renderPage();
const addScopeButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("Scope 추가"));
expect(addScopeButton).toBeDefined();
await act(async () => {
addScopeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.textContent).not.toContain("offline_access");
expect(container.textContent).toContain("old_claim");
const customClaimButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.includes("old_claim"));
expect(customClaimButton).toBeDefined();
await act(async () => {
customClaimButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
await flush();
const scopeInputs = Array.from(
container.querySelectorAll<HTMLInputElement>(
'input[placeholder="e.g. profile"]',
),
);
expect(scopeInputs.some((input) => input.value === "old_claim")).toBe(true);
});
it("blocks saving a number RP claim default value that is not numeric", async () => {
const { container } = await renderPage();

View File

@@ -73,6 +73,13 @@ interface ScopeItem {
locked?: boolean;
}
interface ScopeCandidate {
id: string;
name: string;
description: string;
source: "standard" | "custom_claim" | "manual";
}
type ClaimNamespace = "rp_claims";
type ClaimValueType =
| "text"
@@ -626,6 +633,7 @@ function ClientGeneralPage() {
const [jwksUri, setJwksUri] = useState("");
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
const [isScopePickerOpen, setIsScopePickerOpen] = useState(false);
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
{
id: "1",
@@ -712,6 +720,92 @@ function ClientGeneralPage() {
[buildTenantScope, tenantScopeDescription],
);
const supportedScopeCandidates = useMemo<ScopeCandidate[]>(
() => [
{
id: "standard-openid",
name: "openid",
description: t(
"msg.dev.clients.scopes.openid",
"OIDC 인증 필수 스코프",
),
source: "standard",
},
{
id: "standard-profile",
name: "profile",
description: t(
"msg.dev.clients.scopes.profile",
"기본 프로필 정보 접근",
),
source: "standard",
},
{
id: "standard-email",
name: "email",
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
source: "standard",
},
{
id: "standard-tenant",
name: "tenant",
description: tenantScopeDescription,
source: "standard",
},
],
[tenantScopeDescription],
);
const customClaimScopeCandidates = useMemo<ScopeCandidate[]>(() => {
const seen = new Set<string>();
const candidates: ScopeCandidate[] = [];
for (const claim of idTokenClaims) {
const name = claim.key.trim();
if (!name || seen.has(name)) {
continue;
}
seen.add(name);
candidates.push({
id: `custom-claim-${name}`,
name,
description: t(
"msg.dev.clients.scopes.custom_claim",
"Custom Claim 요청 scope",
),
source: "custom_claim",
});
}
return candidates;
}, [idTokenClaims]);
const scopeCandidates = useMemo<ScopeCandidate[]>(
() => [
...supportedScopeCandidates,
...customClaimScopeCandidates,
{
id: "manual-scope",
name: "",
description: t(
"msg.dev.clients.scopes.manual",
"목록에 없는 scope를 직접 입력합니다.",
),
source: "manual",
},
],
[customClaimScopeCandidates, supportedScopeCandidates],
);
const existingScopeNames = useMemo(() => {
const names = new Set<string>();
for (const scope of scopes) {
const name = scope.name.trim();
if (name) {
names.add(name);
}
}
return names;
}, [scopes]);
useEffect(() => {
if (!data) return;
const { client } = data;
@@ -904,11 +998,28 @@ function ClientGeneralPage() {
};
const addScope = () => {
const newId = String(Date.now());
setScopes([
...scopes,
{ id: newId, name: "", description: "", mandatory: false },
]);
setIsScopePickerOpen((current) => !current);
};
const selectScopeCandidate = (candidate: ScopeCandidate) => {
const name = candidate.name.trim();
if (name && existingScopeNames.has(name)) {
setIsScopePickerOpen(false);
return;
}
const newScope: ScopeItem = {
id: `scope-${candidate.source}-${name || "manual"}-${Date.now()}`,
name,
description: candidate.source === "manual" ? "" : candidate.description,
mandatory: false,
};
setScopes((current) =>
normalizeScopesForTenantAccess(
[...current, newScope],
tenantAccessRestricted,
),
);
setIsScopePickerOpen(false);
};
const updateScope = <K extends keyof ScopeItem>(
@@ -1852,12 +1963,99 @@ function ClientGeneralPage() {
onClick={addScope}
className="gap-2"
disabled={isGeneralSettingsReadOnly}
aria-expanded={isScopePickerOpen}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
</Button>
</CardHeader>
<CardContent className="space-y-6">
{isScopePickerOpen && (
<div className="space-y-3 rounded-md border border-border bg-muted/10 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold">
{t(
"ui.dev.clients.general.scopes.picker_title",
"추가할 scope 선택",
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.general.scopes.picker_help",
"지원 scope와 Custom Claim key를 선택해 scope 목록에 추가합니다.",
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setIsScopePickerOpen(false)}
aria-label={t(
"ui.dev.clients.general.scopes.close_picker",
"scope 선택 닫기",
)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{scopeCandidates.map((candidate) => {
const isManual = candidate.source === "manual";
const isDuplicate =
candidate.name.trim() !== "" &&
existingScopeNames.has(candidate.name.trim());
return (
<button
key={candidate.id}
type="button"
className={cn(
"flex min-h-16 items-start justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-left text-sm transition-colors",
isDuplicate
? "cursor-not-allowed opacity-50"
: "hover:border-primary/60 hover:bg-primary/5",
)}
onClick={() => selectScopeCandidate(candidate)}
disabled={isDuplicate || isGeneralSettingsReadOnly}
>
<span className="min-w-0 space-y-1">
<span className="block font-mono text-xs font-semibold">
{isManual
? t(
"ui.dev.clients.general.scopes.manual_input",
"직접 입력",
)
: candidate.name}
</span>
<span className="block text-xs text-muted-foreground">
{candidate.description}
</span>
</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{candidate.source === "custom_claim"
? t(
"ui.dev.clients.general.scopes.source_custom_claim",
"Custom Claim",
)
: candidate.source === "manual"
? t(
"ui.dev.clients.general.scopes.source_manual",
"Manual",
)
: t(
"ui.dev.clients.general.scopes.source_standard",
"Standard",
)}
</Badge>
</button>
);
})}
</div>
</div>
)}
{isCreate && (
<div className="space-y-2 border-b border-border pb-6 mb-6">
<Label className="text-sm font-semibold">

View File

@@ -99,6 +99,70 @@ test.describe("DevFront RP claim cache", () => {
await expect(claimKeyInput).toHaveValue("new_claim");
});
test("adds supported scopes and custom claim keys from the scope picker without offline_access", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {
structured_scopes: [
{
id: "scope-openid",
name: "openid",
description: "OIDC",
mandatory: true,
},
],
id_token_claims: [
{
namespace: "rp_claims",
key: "employee_code",
value: "E001",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
],
consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-claims/settings");
await page
.getByRole("button", { name: /스코프 추가|Scope 추가|Add Scope/i })
.click();
await expect(page.getByText("offline_access", { exact: true })).toHaveCount(
0,
);
await expect(
page.getByRole("button", { name: /employee_code/ }),
).toBeVisible();
await page.getByRole("button", { name: /employee_code/ }).click();
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(() =>
(
state.clients[0]?.metadata?.structured_scopes as
| Array<{ name?: string }>
| undefined
)?.some((scope) => scope.name === "employee_code"),
)
.toBe(true);
});
test("forces read permission on when write permission is enabled", async ({
page,
}) => {