forked from baron/baron-sso
RP 정책 설정 UI 수정
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
|
import { ScrollArea } from "../../components/ui/scroll-area";
|
||||||
import { Switch } from "../../components/ui/switch";
|
import { Switch } from "../../components/ui/switch";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
createClient,
|
createClient,
|
||||||
deleteClient,
|
deleteClient,
|
||||||
fetchClient,
|
fetchClient,
|
||||||
|
fetchTenants,
|
||||||
refreshHeadlessJwksCache,
|
refreshHeadlessJwksCache,
|
||||||
revokeHeadlessJwksCache,
|
revokeHeadlessJwksCache,
|
||||||
updateClient,
|
updateClient,
|
||||||
@@ -40,6 +42,7 @@ import type {
|
|||||||
ClientStatus,
|
ClientStatus,
|
||||||
ClientType,
|
ClientType,
|
||||||
ClientUpsertRequest,
|
ClientUpsertRequest,
|
||||||
|
TenantSummary,
|
||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
@@ -50,6 +53,7 @@ interface ScopeItem {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
mandatory: boolean;
|
mandatory: boolean;
|
||||||
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecurityProfile = "private" | "pkce";
|
type SecurityProfile = "private" | "pkce";
|
||||||
@@ -131,6 +135,10 @@ function ClientGeneralPage() {
|
|||||||
queryFn: () => fetchClient(clientId as string),
|
queryFn: () => fetchClient(clientId as string),
|
||||||
enabled: !isCreate,
|
enabled: !isCreate,
|
||||||
});
|
});
|
||||||
|
const { data: tenantData } = useQuery({
|
||||||
|
queryKey: ["tenants"],
|
||||||
|
queryFn: () => fetchTenants(1000, 0),
|
||||||
|
});
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
@@ -142,6 +150,9 @@ function ClientGeneralPage() {
|
|||||||
const [status, setStatus] = useState<ClientStatus>("active");
|
const [status, setStatus] = useState<ClientStatus>("active");
|
||||||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||||||
const [redirectUris, setRedirectUris] = useState("");
|
const [redirectUris, setRedirectUris] = useState("");
|
||||||
|
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||||
|
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||||
|
const [tenantSearch, setTenantSearch] = useState("");
|
||||||
|
|
||||||
// Public Key Registration States
|
// Public Key Registration States
|
||||||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||||||
@@ -168,6 +179,15 @@ function ClientGeneralPage() {
|
|||||||
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
|
description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"),
|
||||||
mandatory: false,
|
mandatory: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
name: "tenant",
|
||||||
|
description: t(
|
||||||
|
"msg.dev.clients.scopes.tenant",
|
||||||
|
"소속 테넌트 정보 접근",
|
||||||
|
),
|
||||||
|
mandatory: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -185,6 +205,16 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const headlessEnabled = !!metadata.headless_login_enabled;
|
const headlessEnabled = !!metadata.headless_login_enabled;
|
||||||
setHeadlessLoginEnabled(headlessEnabled);
|
setHeadlessLoginEnabled(headlessEnabled);
|
||||||
|
const restrictedTenants = Array.isArray(metadata.allowed_tenants)
|
||||||
|
? metadata.allowed_tenants
|
||||||
|
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
setTenantAccessRestricted(
|
||||||
|
restrictedTenants.length > 0 ||
|
||||||
|
metadata.tenant_access_restricted === true,
|
||||||
|
);
|
||||||
|
setAllowedTenantIds(restrictedTenants);
|
||||||
|
|
||||||
const savedAuthMethod =
|
const savedAuthMethod =
|
||||||
client.tokenEndpointAuthMethod ||
|
client.tokenEndpointAuthMethod ||
|
||||||
@@ -230,15 +260,25 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined;
|
||||||
if (savedScopes && Array.isArray(savedScopes)) {
|
if (savedScopes && Array.isArray(savedScopes)) {
|
||||||
setScopes(savedScopes);
|
setScopes(
|
||||||
|
normalizeScopesForTenantAccess(
|
||||||
|
savedScopes,
|
||||||
|
restrictedTenants.length > 0 ||
|
||||||
|
metadata.tenant_access_restricted === true,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setScopes(
|
setScopes(
|
||||||
client.scopes.map((s, idx) => ({
|
normalizeScopesForTenantAccess(
|
||||||
id: String(idx + 1),
|
client.scopes.map((s, idx) => ({
|
||||||
name: s,
|
id: String(idx + 1),
|
||||||
description: "",
|
name: s,
|
||||||
mandatory: s === "openid",
|
description: "",
|
||||||
})),
|
mandatory: s === "openid",
|
||||||
|
})),
|
||||||
|
restrictedTenants.length > 0 ||
|
||||||
|
metadata.tenant_access_restricted === true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -279,6 +319,55 @@ function ClientGeneralPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tenantScopeDescription = t(
|
||||||
|
"msg.dev.clients.scopes.tenant",
|
||||||
|
"소속 테넌트 정보 접근",
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildTenantScope = (id: string): ScopeItem => ({
|
||||||
|
id,
|
||||||
|
name: "tenant",
|
||||||
|
description: tenantScopeDescription,
|
||||||
|
mandatory: true,
|
||||||
|
locked: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeScopesForTenantAccess(
|
||||||
|
nextScopes: ScopeItem[],
|
||||||
|
restricted: boolean,
|
||||||
|
): ScopeItem[] {
|
||||||
|
const normalized = nextScopes.map((scope) => {
|
||||||
|
if (scope.name.trim() !== "tenant") {
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...scope,
|
||||||
|
description: scope.description || tenantScopeDescription,
|
||||||
|
mandatory: restricted ? true : false,
|
||||||
|
locked: restricted,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (restricted && !normalized.some((scope) => scope.name.trim() === "tenant")) {
|
||||||
|
normalized.push(buildTenantScope(`tenant-${Date.now()}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTenantAccessToggle = (enabled: boolean) => {
|
||||||
|
setTenantAccessRestricted(enabled);
|
||||||
|
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAllowedTenant = (tenantId: string) => {
|
||||||
|
setAllowedTenantIds((current) =>
|
||||||
|
current.includes(tenantId)
|
||||||
|
? current.filter((id) => id !== tenantId)
|
||||||
|
: [...current, tenantId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const addScope = () => {
|
const addScope = () => {
|
||||||
const newId = String(Date.now());
|
const newId = String(Date.now());
|
||||||
setScopes([
|
setScopes([
|
||||||
@@ -292,15 +381,23 @@ function ClientGeneralPage() {
|
|||||||
field: K,
|
field: K,
|
||||||
value: ScopeItem[K],
|
value: ScopeItem[K],
|
||||||
) => {
|
) => {
|
||||||
setScopes(
|
setScopes((current) =>
|
||||||
scopes.map((scope) =>
|
current.map((scope) => {
|
||||||
scope.id === id ? { ...scope, [field]: value } : scope,
|
if (scope.id !== id) {
|
||||||
),
|
return scope;
|
||||||
|
}
|
||||||
|
if (scope.locked) {
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
return { ...scope, [field]: value };
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeScope = (id: string) => {
|
const removeScope = (id: string) => {
|
||||||
setScopes(scopes.filter((s) => s.id !== id));
|
setScopes((current) =>
|
||||||
|
current.filter((scope) => scope.id !== id || scope.locked === true),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = (nextStatus: ClientStatus) => {
|
const handleStatusChange = (nextStatus: ClientStatus) => {
|
||||||
@@ -391,7 +488,25 @@ function ClientGeneralPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tenantAccessRestricted && allowedTenantIds.length === 0) {
|
||||||
|
validationErrors.push(
|
||||||
|
t(
|
||||||
|
"ui.dev.clients.general.tenant_access.validation_required",
|
||||||
|
"테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hasValidationErrors = validationErrors.length > 0;
|
const hasValidationErrors = validationErrors.length > 0;
|
||||||
|
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
||||||
|
const tenantOptions: TenantSummary[] = tenantData?.items ?? [];
|
||||||
|
const filteredTenants = tenantOptions.filter((tenant) => {
|
||||||
|
if (!normalizedTenantSearch) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const searchable = `${tenant.name} ${tenant.slug} ${tenant.description} ${tenant.type}`.toLowerCase();
|
||||||
|
return searchable.includes(normalizedTenantSearch);
|
||||||
|
});
|
||||||
|
|
||||||
const refreshHeadlessJwksCacheMutation = useMutation({
|
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -467,7 +582,16 @@ function ClientGeneralPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
const normalizedScopes = normalizeScopesForTenantAccess(
|
||||||
|
scopes,
|
||||||
|
tenantAccessRestricted,
|
||||||
|
);
|
||||||
|
const normalizedAllowedTenantIds = Array.from(
|
||||||
|
new Set(allowedTenantIds.map((id) => id.trim()).filter(Boolean)),
|
||||||
|
);
|
||||||
|
const scopeNames = normalizedScopes
|
||||||
|
.map((scope) => scope.name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const effectiveTokenEndpointAuthMethod =
|
const effectiveTokenEndpointAuthMethod =
|
||||||
clientType === "pkce" && headlessLoginEnabled
|
clientType === "pkce" && headlessLoginEnabled
|
||||||
@@ -487,7 +611,7 @@ function ClientGeneralPage() {
|
|||||||
metadata: {
|
metadata: {
|
||||||
description,
|
description,
|
||||||
logo_url: trimmedLogoUrl,
|
logo_url: trimmedLogoUrl,
|
||||||
structured_scopes: scopes,
|
structured_scopes: normalizedScopes,
|
||||||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||||
headless_login_enabled: headlessLoginEnabled,
|
headless_login_enabled: headlessLoginEnabled,
|
||||||
headless_token_endpoint_auth_method:
|
headless_token_endpoint_auth_method:
|
||||||
@@ -498,6 +622,10 @@ function ClientGeneralPage() {
|
|||||||
clientType === "pkce" && headlessLoginEnabled
|
clientType === "pkce" && headlessLoginEnabled
|
||||||
? trimmedJwksUri
|
? trimmedJwksUri
|
||||||
: undefined,
|
: undefined,
|
||||||
|
tenant_access_restricted: tenantAccessRestricted,
|
||||||
|
allowed_tenants: tenantAccessRestricted
|
||||||
|
? normalizedAllowedTenantIds
|
||||||
|
: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -972,7 +1100,10 @@ function ClientGeneralPage() {
|
|||||||
{scopes.map((s) => (
|
{scopes.map((s) => (
|
||||||
<tr
|
<tr
|
||||||
key={s.id}
|
key={s.id}
|
||||||
className="hover:bg-muted/30 transition-colors"
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
s.locked ? "bg-primary/5" : "hover:bg-muted/30",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Input
|
<Input
|
||||||
@@ -985,6 +1116,7 @@ function ClientGeneralPage() {
|
|||||||
"ui.dev.clients.general.scopes.name_placeholder",
|
"ui.dev.clients.general.scopes.name_placeholder",
|
||||||
"e.g. profile",
|
"e.g. profile",
|
||||||
)}
|
)}
|
||||||
|
disabled={s.locked}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -998,6 +1130,7 @@ function ClientGeneralPage() {
|
|||||||
"ui.dev.clients.general.scopes.description_placeholder",
|
"ui.dev.clients.general.scopes.description_placeholder",
|
||||||
"권한에 대한 설명",
|
"권한에 대한 설명",
|
||||||
)}
|
)}
|
||||||
|
disabled={s.locked}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
@@ -1007,6 +1140,7 @@ function ClientGeneralPage() {
|
|||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
updateScope(s.id, "mandatory", checked)
|
updateScope(s.id, "mandatory", checked)
|
||||||
}
|
}
|
||||||
|
disabled={s.locked}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -1016,6 +1150,7 @@ function ClientGeneralPage() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => removeScope(s.id)}
|
onClick={() => removeScope(s.id)}
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
disabled={s.locked}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1041,6 +1176,187 @@ function ClientGeneralPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-panel">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-xl font-bold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.title",
|
||||||
|
"테넌트 접근 제한",
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.subtitle",
|
||||||
|
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||||
|
<div className="space-y-0.5 text-right">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{tenantAccessRestricted
|
||||||
|
? t(
|
||||||
|
"ui.dev.clients.general.tenant_access.enabled",
|
||||||
|
"제한 있음",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"ui.dev.clients.general.tenant_access.disabled",
|
||||||
|
"제한 없음",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.title",
|
||||||
|
"테넌트 접근 제한",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={tenantAccessRestricted}
|
||||||
|
onCheckedChange={handleTenantAccessToggle}
|
||||||
|
id="tenant-access-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.hint",
|
||||||
|
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="tenant-search" className="text-sm font-semibold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||||||
|
"테넌트 이름 또는 슬러그로 검색",
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tenant-search"
|
||||||
|
value={tenantSearch}
|
||||||
|
onChange={(e) => setTenantSearch(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||||||
|
"테넌트 이름 또는 슬러그로 검색",
|
||||||
|
)}
|
||||||
|
disabled={!tenantAccessRestricted}
|
||||||
|
/>
|
||||||
|
<ScrollArea className="h-72 rounded-xl border border-border bg-card">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{tenantAccessRestricted ? (
|
||||||
|
filteredTenants.length > 0 ? (
|
||||||
|
filteredTenants.map((tenant) => {
|
||||||
|
const checked = allowedTenantIds.includes(tenant.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={tenant.id}
|
||||||
|
className="flex cursor-pointer items-center justify-between gap-4 px-4 py-3 transition hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{tenant.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-[11px]">
|
||||||
|
{tenant.slug}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{tenant.description || tenant.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleAllowedTenant(tenant.id)}
|
||||||
|
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.empty",
|
||||||
|
"검색 결과가 없습니다.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.disabled",
|
||||||
|
"제한 없음",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">
|
||||||
|
{t(
|
||||||
|
"ui.dev.clients.general.tenant_access.selected_title",
|
||||||
|
"허용 테넌트",
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
|
||||||
|
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allowedTenantIds.map((tenantId) => {
|
||||||
|
const tenant = tenantData?.items.find(
|
||||||
|
(item) => item.id === tenantId,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={tenantId}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-2 px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="max-w-44 truncate">
|
||||||
|
{tenant?.name || tenantId}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t(
|
||||||
|
"ui.common.delete",
|
||||||
|
"삭제",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleAllowedTenant(tenantId)}
|
||||||
|
className="text-xs font-semibold text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{tenantAccessRestricted
|
||||||
|
? t(
|
||||||
|
"ui.dev.clients.general.tenant_access.selected_empty",
|
||||||
|
"아직 선택된 테넌트가 없습니다.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"ui.dev.clients.general.tenant_access.disabled",
|
||||||
|
"제한 없음",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 3. Security Settings */}
|
{/* 3. Security Settings */}
|
||||||
<Card className="glass-panel">
|
<Card className="glass-panel">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|||||||
@@ -23,6 +23,28 @@ export type ClientListResponse = {
|
|||||||
offset: number;
|
offset: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TenantSummary = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
domains?: string[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantListResponse = {
|
||||||
|
items: TenantSummary[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type DevStats = {
|
export type DevStats = {
|
||||||
total_clients: number;
|
total_clients: number;
|
||||||
active_sessions: number;
|
active_sessions: number;
|
||||||
@@ -188,6 +210,17 @@ export async function fetchDevStats() {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTenants(
|
||||||
|
limit = 1000,
|
||||||
|
offset = 0,
|
||||||
|
parentId?: string,
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.get<TenantListResponse>("/tenants", {
|
||||||
|
params: { limit, offset, parentId },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchClient(clientId: string) {
|
export async function fetchClient(clientId: string) {
|
||||||
const { data } = await apiClient.get<ClientDetailResponse>(
|
const { data } = await apiClient.get<ClientDetailResponse>(
|
||||||
`/dev/clients/${clientId}`,
|
`/dev/clients/${clientId}`,
|
||||||
@@ -376,14 +409,14 @@ export async function fetchDevAuditLogs(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TenantSummary = {
|
export type MyTenantSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchMyTenants() {
|
export async function fetchMyTenants() {
|
||||||
const { data } = await apiClient.get<TenantSummary[]>("/dev/my-tenants");
|
const { data } = await apiClient.get<MyTenantSummary[]>("/dev/my-tenants");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ help = "Enter the redirect URIs. You can modify them in the Federation tab after
|
|||||||
[msg.dev.clients.general.scopes]
|
[msg.dev.clients.general.scopes]
|
||||||
empty = "No scopes registered."
|
empty = "No scopes registered."
|
||||||
subtitle = "Define the permission scopes this application can request."
|
subtitle = "Define the permission scopes this application can request."
|
||||||
|
tenant = "Tenant access claim"
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||||
@@ -1436,6 +1437,19 @@ description = "Scope Description"
|
|||||||
mandatory = "Mandatory"
|
mandatory = "Mandatory"
|
||||||
name = "Scope Name"
|
name = "Scope Name"
|
||||||
delete = "Delete"
|
delete = "Delete"
|
||||||
|
tenant = "Tenant"
|
||||||
|
|
||||||
|
[ui.dev.clients.general.tenant_access]
|
||||||
|
title = "Tenant access restriction"
|
||||||
|
subtitle = "Limit this RP so only approved tenants can access it."
|
||||||
|
enabled = "Restricted"
|
||||||
|
disabled = "Unrestricted"
|
||||||
|
search_placeholder = "Search by tenant name or slug"
|
||||||
|
selected_title = "Allowed tenants"
|
||||||
|
selected_empty = "No tenants selected yet."
|
||||||
|
empty = "No tenants match your search."
|
||||||
|
hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant."
|
||||||
|
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
private = "Server Side App"
|
private = "Server Side App"
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동
|
|||||||
[msg.dev.clients.general.scopes]
|
[msg.dev.clients.general.scopes]
|
||||||
empty = "등록된 스코프가 없습니다."
|
empty = "등록된 스코프가 없습니다."
|
||||||
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||||
|
tenant = "소속 테넌트 정보 접근"
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||||
@@ -1435,6 +1436,19 @@ description = "설명"
|
|||||||
mandatory = "필수"
|
mandatory = "필수"
|
||||||
name = "스코프 이름"
|
name = "스코프 이름"
|
||||||
delete = "삭제"
|
delete = "삭제"
|
||||||
|
tenant = "테넌트"
|
||||||
|
|
||||||
|
[ui.dev.clients.general.tenant_access]
|
||||||
|
title = "테넌트 접근 제한"
|
||||||
|
subtitle = "허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다."
|
||||||
|
enabled = "제한 있음"
|
||||||
|
disabled = "제한 없음"
|
||||||
|
search_placeholder = "테넌트 이름 또는 슬러그로 검색"
|
||||||
|
selected_title = "허용 테넌트"
|
||||||
|
selected_empty = "아직 선택된 테넌트가 없습니다."
|
||||||
|
empty = "검색 결과가 없습니다."
|
||||||
|
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||||
|
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
private = "Server side App"
|
private = "Server side App"
|
||||||
|
|||||||
@@ -465,6 +465,7 @@ help = ""
|
|||||||
[msg.dev.clients.general.scopes]
|
[msg.dev.clients.general.scopes]
|
||||||
empty = ""
|
empty = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
tenant = ""
|
||||||
|
|
||||||
[msg.dev.clients.general.security]
|
[msg.dev.clients.general.security]
|
||||||
private_help = ""
|
private_help = ""
|
||||||
@@ -1518,6 +1519,19 @@ description = ""
|
|||||||
mandatory = ""
|
mandatory = ""
|
||||||
name = ""
|
name = ""
|
||||||
delete = ""
|
delete = ""
|
||||||
|
tenant = ""
|
||||||
|
|
||||||
|
[ui.dev.clients.general.tenant_access]
|
||||||
|
title = ""
|
||||||
|
subtitle = ""
|
||||||
|
enabled = ""
|
||||||
|
disabled = ""
|
||||||
|
search_placeholder = ""
|
||||||
|
selected_title = ""
|
||||||
|
selected_empty = ""
|
||||||
|
empty = ""
|
||||||
|
hint = ""
|
||||||
|
validation_required = ""
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
private = ""
|
private = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user