forked from baron/baron-sso
테넌트 입력 자동완성형 변경
This commit is contained in:
@@ -2,14 +2,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Plus,
|
||||
Save,
|
||||
Search,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
@@ -24,7 +27,6 @@ import {
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { ScrollArea } from "../../components/ui/scroll-area";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
@@ -153,6 +155,7 @@ function ClientGeneralPage() {
|
||||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||
const [tenantSearch, setTenantSearch] = useState("");
|
||||
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
||||
|
||||
// Public Key Registration States
|
||||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||||
@@ -357,6 +360,10 @@ function ClientGeneralPage() {
|
||||
|
||||
const handleTenantAccessToggle = (enabled: boolean) => {
|
||||
setTenantAccessRestricted(enabled);
|
||||
setIsTenantSearchOpen(enabled);
|
||||
if (!enabled) {
|
||||
setTenantSearch("");
|
||||
}
|
||||
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
||||
};
|
||||
|
||||
@@ -368,6 +375,14 @@ function ClientGeneralPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAllowedTenant = (tenantId: string) => {
|
||||
setAllowedTenantIds((current) =>
|
||||
current.includes(tenantId) ? current : [...current, tenantId],
|
||||
);
|
||||
setTenantSearch("");
|
||||
setIsTenantSearchOpen(true);
|
||||
};
|
||||
|
||||
const addScope = () => {
|
||||
const newId = String(Date.now());
|
||||
setScopes([
|
||||
@@ -507,6 +522,12 @@ function ClientGeneralPage() {
|
||||
const searchable = `${tenant.name} ${tenant.slug} ${tenant.description} ${tenant.type}`.toLowerCase();
|
||||
return searchable.includes(normalizedTenantSearch);
|
||||
});
|
||||
const tenantSuggestions = filteredTenants
|
||||
.filter((tenant) => !allowedTenantIds.includes(tenant.id))
|
||||
.slice(0, 8);
|
||||
const selectedAllowedTenants = allowedTenantIds
|
||||
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
||||
.filter((tenant): tenant is TenantSummary => tenant != null);
|
||||
|
||||
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -1237,49 +1258,61 @@ function ClientGeneralPage() {
|
||||
"테넌트 이름 또는 슬러그로 검색",
|
||||
)}
|
||||
</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 className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="tenant-search"
|
||||
value={tenantSearch}
|
||||
onFocus={() => {
|
||||
if (tenantAccessRestricted) {
|
||||
setIsTenantSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => setIsTenantSearchOpen(false), 120);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setTenantSearch(e.target.value);
|
||||
if (tenantAccessRestricted) {
|
||||
setIsTenantSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그로 검색",
|
||||
)}
|
||||
className="pl-10"
|
||||
disabled={!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">
|
||||
{tenantSuggestions.length > 0 ? (
|
||||
tenantSuggestions.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
className="flex w-full items-start justify-between gap-3 border-b border-border/40 px-4 py-3 text-left transition hover:bg-muted/40 last:border-b-0"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
handleSelectAllowedTenant(tenant.id);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleAllowedTenant(tenant.id)}
|
||||
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{tenant.description || tenant.type}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
@@ -1287,17 +1320,21 @@ function ClientGeneralPage() {
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
{tenantAccessRestricted
|
||||
? t(
|
||||
"ui.dev.clients.general.tenant_access.autocomplete_hint",
|
||||
"테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.",
|
||||
)
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.disabled",
|
||||
"제한 없음",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
: t(
|
||||
"ui.dev.clients.general.tenant_access.disabled",
|
||||
"제한 없음",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -1310,19 +1347,45 @@ function ClientGeneralPage() {
|
||||
<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 (
|
||||
{selectedAllowedTenants.map((tenant) => (
|
||||
<Badge
|
||||
key={tenant.id}
|
||||
variant="secondary"
|
||||
className="gap-2 px-3 py-1.5"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span className="max-w-44 truncate">{tenant.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{tenant.slug}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(
|
||||
"ui.common.delete",
|
||||
"삭제",
|
||||
)}
|
||||
onClick={() => toggleAllowedTenant(tenant.id)}
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{allowedTenantIds
|
||||
.filter(
|
||||
(tenantId) =>
|
||||
!selectedAllowedTenants.some(
|
||||
(tenant) => tenant.id === tenantId,
|
||||
),
|
||||
)
|
||||
.map((tenantId) => (
|
||||
<Badge
|
||||
key={tenantId}
|
||||
variant="secondary"
|
||||
className="gap-2 px-3 py-1.5"
|
||||
>
|
||||
<span className="max-w-44 truncate">
|
||||
{tenant?.name || tenantId}
|
||||
</span>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span className="max-w-44 truncate">{tenantId}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(
|
||||
@@ -1330,13 +1393,12 @@ function ClientGeneralPage() {
|
||||
"삭제",
|
||||
)}
|
||||
onClick={() => toggleAllowedTenant(tenantId)}
|
||||
className="text-xs font-semibold text-muted-foreground hover:text-destructive"
|
||||
className="text-muted-foreground transition hover:text-destructive"
|
||||
>
|
||||
×
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -1449,6 +1449,7 @@ 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."
|
||||
autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list."
|
||||
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
|
||||
@@ -1448,6 +1448,7 @@ selected_title = "허용 테넌트"
|
||||
selected_empty = "아직 선택된 테넌트가 없습니다."
|
||||
empty = "검색 결과가 없습니다."
|
||||
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
|
||||
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
|
||||
@@ -1531,6 +1531,7 @@ selected_title = ""
|
||||
selected_empty = ""
|
||||
empty = ""
|
||||
hint = ""
|
||||
autocomplete_hint = ""
|
||||
validation_required = ""
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
|
||||
Reference in New Issue
Block a user