forked from baron/baron-sso
테넌트 접근 제한/커스텀 클레임 관계 설정
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -31,9 +32,11 @@ import { Switch } from "../../components/ui/switch";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type ClientRelation,
|
||||
createClient,
|
||||
deleteClient,
|
||||
fetchClient,
|
||||
fetchClientRelations,
|
||||
fetchMyTenants,
|
||||
refreshHeadlessJwksCache,
|
||||
revokeHeadlessJwksCache,
|
||||
@@ -48,6 +51,7 @@ import type {
|
||||
TenantSummary,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
|
||||
@@ -275,16 +279,27 @@ function formatDateTime(value?: string) {
|
||||
}
|
||||
|
||||
function ClientGeneralPage() {
|
||||
const auth = useAuth();
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const clientId = params.id;
|
||||
const isCreate = !clientId;
|
||||
const currentUserId = auth.user?.profile.sub;
|
||||
const systemRole = resolveProfileRole(
|
||||
auth.user?.profile as Record<string, unknown> | undefined,
|
||||
);
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["client", clientId],
|
||||
queryFn: () => fetchClient(clientId as string),
|
||||
enabled: !isCreate,
|
||||
});
|
||||
const { data: relationData } = useQuery({
|
||||
queryKey: ["client-relations", clientId],
|
||||
queryFn: () => fetchClientRelations(clientId as string),
|
||||
enabled: !isCreate,
|
||||
retry: false,
|
||||
});
|
||||
const { data: tenantData } = useQuery({
|
||||
queryKey: ["my-tenants"],
|
||||
queryFn: fetchMyTenants,
|
||||
@@ -440,6 +455,17 @@ function ClientGeneralPage() {
|
||||
|
||||
const securityProfile: SecurityProfile =
|
||||
clientType === "pkce" ? "pkce" : "private";
|
||||
const canEditExistingClientGeneralSettings =
|
||||
systemRole === "super_admin" ||
|
||||
relationData?.items?.some(
|
||||
(item: ClientRelation) =>
|
||||
item.subject === `User:${currentUserId}` &&
|
||||
(item.relation === "admins" || item.relation === "config_editor"),
|
||||
) === true;
|
||||
const isGeneralSettingsReadOnly =
|
||||
!isCreate &&
|
||||
relationData != null &&
|
||||
!canEditExistingClientGeneralSettings;
|
||||
const trimmedLogoUrl = logoUrl.trim();
|
||||
const trimmedAutoLoginUrl = autoLoginUrl.trim();
|
||||
const hasLogoUrl = trimmedLogoUrl.length > 0;
|
||||
@@ -869,6 +895,14 @@ function ClientGeneralPage() {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isGeneralSettingsReadOnly) {
|
||||
throw new Error(
|
||||
t(
|
||||
"msg.dev.clients.general.read_only_forbidden",
|
||||
"이 RP의 일반 설정을 수정할 권한이 없습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (autoLoginSupported && !hasValidAutoLoginUrl) {
|
||||
throw new Error(
|
||||
t(
|
||||
@@ -1114,6 +1148,14 @@ function ClientGeneralPage() {
|
||||
{!isCreate && (
|
||||
<ClientDetailTabs activeTab="settings" clientId={clientId} />
|
||||
)}
|
||||
{isGeneralSettingsReadOnly && (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
|
||||
{t(
|
||||
"msg.dev.clients.general.read_only_hint",
|
||||
"이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 1. Application Identity */}
|
||||
@@ -1148,6 +1190,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.identity.name_placeholder",
|
||||
"My Awesome Application",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -1165,6 +1208,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.identity.description_placeholder",
|
||||
"앱에 대한 간단한 설명을 입력하세요.",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1184,6 +1228,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.identity.logo_placeholder",
|
||||
"https://example.com/logo.png",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
@@ -1302,6 +1347,7 @@ function ClientGeneralPage() {
|
||||
size="sm"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => handleStatusChange("active")}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
@@ -1310,6 +1356,7 @@ function ClientGeneralPage() {
|
||||
size="sm"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => handleStatusChange("inactive")}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
@@ -1416,6 +1463,7 @@ function ClientGeneralPage() {
|
||||
size="sm"
|
||||
onClick={addScope}
|
||||
className="gap-2"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.general.scopes.add", "Scope 추가")}
|
||||
@@ -1436,6 +1484,7 @@ function ClientGeneralPage() {
|
||||
"https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)",
|
||||
)}
|
||||
className="font-mono text-sm"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
@@ -1493,7 +1542,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.scopes.name_placeholder",
|
||||
"e.g. profile",
|
||||
)}
|
||||
disabled={s.locked}
|
||||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -1507,7 +1556,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.scopes.description_placeholder",
|
||||
"권한에 대한 설명",
|
||||
)}
|
||||
disabled={s.locked}
|
||||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
@@ -1517,7 +1566,7 @@ function ClientGeneralPage() {
|
||||
onCheckedChange={(checked) =>
|
||||
updateScope(s.id, "mandatory", checked)
|
||||
}
|
||||
disabled={s.locked}
|
||||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1527,7 +1576,7 @@ function ClientGeneralPage() {
|
||||
size="icon"
|
||||
onClick={() => removeScope(s.id)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={s.locked}
|
||||
disabled={s.locked || isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -1594,6 +1643,7 @@ function ClientGeneralPage() {
|
||||
checked={tenantAccessRestricted}
|
||||
onCheckedChange={handleTenantAccessToggle}
|
||||
id="tenant-access-toggle"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1638,7 +1688,9 @@ function ClientGeneralPage() {
|
||||
"테넌트 이름 또는 슬러그로 검색",
|
||||
)}
|
||||
className="pl-10"
|
||||
disabled={!tenantAccessRestricted}
|
||||
disabled={
|
||||
isGeneralSettingsReadOnly || !tenantAccessRestricted
|
||||
}
|
||||
/>
|
||||
{tenantAccessRestricted && isTenantSearchOpen && (
|
||||
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
|
||||
@@ -1652,6 +1704,7 @@ function ClientGeneralPage() {
|
||||
event.preventDefault();
|
||||
handleSelectAllowedTenant(tenant.id);
|
||||
}}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1719,6 +1772,7 @@ function ClientGeneralPage() {
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() => toggleAllowedTenant(tenant.id)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -1744,6 +1798,7 @@ function ClientGeneralPage() {
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() => toggleAllowedTenant(tenantId)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -1786,7 +1841,11 @@ function ClientGeneralPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={addIdTokenClaim} className="gap-2">
|
||||
<Button
|
||||
onClick={addIdTokenClaim}
|
||||
className="gap-2"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("ui.dev.clients.general.id_token_claims.add", "Claim 추가")}
|
||||
</Button>
|
||||
@@ -1849,6 +1908,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.id_token_claims.key_placeholder",
|
||||
"e.g. locale",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
@@ -1866,6 +1926,7 @@ function ClientGeneralPage() {
|
||||
"Claim namespace",
|
||||
)}
|
||||
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="top_level">
|
||||
{t(
|
||||
@@ -1896,6 +1957,7 @@ function ClientGeneralPage() {
|
||||
"Claim value type",
|
||||
)}
|
||||
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="text">
|
||||
{t(
|
||||
@@ -1944,6 +2006,7 @@ function ClientGeneralPage() {
|
||||
"ui.dev.clients.general.id_token_claims.value_placeholder",
|
||||
"Enter the claim value",
|
||||
)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right align-top">
|
||||
@@ -1952,6 +2015,7 @@ function ClientGeneralPage() {
|
||||
size="icon"
|
||||
onClick={() => removeIdTokenClaim(claim.id)}
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -2116,6 +2180,7 @@ function ClientGeneralPage() {
|
||||
id="headless-login-toggle"
|
||||
checked={headlessLoginEnabled}
|
||||
onCheckedChange={handleHeadlessToggle}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2589,6 +2654,7 @@ function ClientGeneralPage() {
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={
|
||||
isGeneralSettingsReadOnly ||
|
||||
mutation.isPending ||
|
||||
isLoading ||
|
||||
name.trim() === "" ||
|
||||
|
||||
@@ -47,7 +47,6 @@ const relationOptions = [
|
||||
"consent_revoker",
|
||||
"relationship_viewer",
|
||||
"audit_viewer",
|
||||
"status_operator",
|
||||
] as const;
|
||||
|
||||
type RelationOption = (typeof relationOptions)[number];
|
||||
|
||||
Reference in New Issue
Block a user