forked from baron/baron-sso
offline_access 제거 확인 추가 및 scope 선택 개선
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user