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 () => {
|
it("blocks saving a number RP claim default value that is not numeric", async () => {
|
||||||
const { container } = await renderPage();
|
const { container } = await renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ interface ScopeItem {
|
|||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScopeCandidate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
source: "standard" | "custom_claim" | "manual";
|
||||||
|
}
|
||||||
|
|
||||||
type ClaimNamespace = "rp_claims";
|
type ClaimNamespace = "rp_claims";
|
||||||
type ClaimValueType =
|
type ClaimValueType =
|
||||||
| "text"
|
| "text"
|
||||||
@@ -626,6 +633,7 @@ function ClientGeneralPage() {
|
|||||||
const [jwksUri, setJwksUri] = useState("");
|
const [jwksUri, setJwksUri] = useState("");
|
||||||
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
||||||
|
|
||||||
|
const [isScopePickerOpen, setIsScopePickerOpen] = useState(false);
|
||||||
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
const [scopes, setScopes] = useState<ScopeItem[]>(() => [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
@@ -712,6 +720,92 @@ function ClientGeneralPage() {
|
|||||||
[buildTenantScope, tenantScopeDescription],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const { client } = data;
|
const { client } = data;
|
||||||
@@ -904,11 +998,28 @@ function ClientGeneralPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addScope = () => {
|
const addScope = () => {
|
||||||
const newId = String(Date.now());
|
setIsScopePickerOpen((current) => !current);
|
||||||
setScopes([
|
};
|
||||||
...scopes,
|
|
||||||
{ id: newId, name: "", description: "", mandatory: false },
|
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>(
|
const updateScope = <K extends keyof ScopeItem>(
|
||||||
@@ -1852,12 +1963,99 @@ function ClientGeneralPage() {
|
|||||||
onClick={addScope}
|
onClick={addScope}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
disabled={isGeneralSettingsReadOnly}
|
disabled={isGeneralSettingsReadOnly}
|
||||||
|
aria-expanded={isScopePickerOpen}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<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 && (
|
{isCreate && (
|
||||||
<div className="space-y-2 border-b border-border pb-6 mb-6">
|
<div className="space-y-2 border-b border-border pb-6 mb-6">
|
||||||
<Label className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
|
|||||||
@@ -99,6 +99,70 @@ test.describe("DevFront RP claim cache", () => {
|
|||||||
await expect(claimKeyInput).toHaveValue("new_claim");
|
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 ({
|
test("forces read permission on when write permission is enabled", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user