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 type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
|
Search,
|
||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -24,7 +27,6 @@ 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";
|
||||||
@@ -153,6 +155,7 @@ function ClientGeneralPage() {
|
|||||||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||||
const [tenantSearch, setTenantSearch] = useState("");
|
const [tenantSearch, setTenantSearch] = useState("");
|
||||||
|
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
||||||
|
|
||||||
// Public Key Registration States
|
// Public Key Registration States
|
||||||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||||||
@@ -357,6 +360,10 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const handleTenantAccessToggle = (enabled: boolean) => {
|
const handleTenantAccessToggle = (enabled: boolean) => {
|
||||||
setTenantAccessRestricted(enabled);
|
setTenantAccessRestricted(enabled);
|
||||||
|
setIsTenantSearchOpen(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
setTenantSearch("");
|
||||||
|
}
|
||||||
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
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 addScope = () => {
|
||||||
const newId = String(Date.now());
|
const newId = String(Date.now());
|
||||||
setScopes([
|
setScopes([
|
||||||
@@ -507,6 +522,12 @@ function ClientGeneralPage() {
|
|||||||
const searchable = `${tenant.name} ${tenant.slug} ${tenant.description} ${tenant.type}`.toLowerCase();
|
const searchable = `${tenant.name} ${tenant.slug} ${tenant.description} ${tenant.type}`.toLowerCase();
|
||||||
return searchable.includes(normalizedTenantSearch);
|
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({
|
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -1237,49 +1258,61 @@ function ClientGeneralPage() {
|
|||||||
"테넌트 이름 또는 슬러그로 검색",
|
"테넌트 이름 또는 슬러그로 검색",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="tenant-search"
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
value={tenantSearch}
|
<Input
|
||||||
onChange={(e) => setTenantSearch(e.target.value)}
|
id="tenant-search"
|
||||||
placeholder={t(
|
value={tenantSearch}
|
||||||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
onFocus={() => {
|
||||||
"테넌트 이름 또는 슬러그로 검색",
|
if (tenantAccessRestricted) {
|
||||||
)}
|
setIsTenantSearchOpen(true);
|
||||||
disabled={!tenantAccessRestricted}
|
}
|
||||||
/>
|
}}
|
||||||
<ScrollArea className="h-72 rounded-xl border border-border bg-card">
|
onBlur={() => {
|
||||||
<div className="divide-y divide-border">
|
window.setTimeout(() => setIsTenantSearchOpen(false), 120);
|
||||||
{tenantAccessRestricted ? (
|
}}
|
||||||
filteredTenants.length > 0 ? (
|
onChange={(e) => {
|
||||||
filteredTenants.map((tenant) => {
|
setTenantSearch(e.target.value);
|
||||||
const checked = allowedTenantIds.includes(tenant.id);
|
if (tenantAccessRestricted) {
|
||||||
return (
|
setIsTenantSearchOpen(true);
|
||||||
<label
|
}
|
||||||
key={tenant.id}
|
}}
|
||||||
className="flex cursor-pointer items-center justify-between gap-4 px-4 py-3 transition hover:bg-muted/30"
|
placeholder={t(
|
||||||
>
|
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||||||
<div className="min-w-0 space-y-1">
|
"테넌트 이름 또는 슬러그로 검색",
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<span className="truncate font-medium">
|
className="pl-10"
|
||||||
{tenant.name}
|
disabled={!tenantAccessRestricted}
|
||||||
</span>
|
/>
|
||||||
<Badge variant="outline" className="text-[11px]">
|
{tenantAccessRestricted && isTenantSearchOpen && (
|
||||||
{tenant.slug}
|
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
|
||||||
</Badge>
|
{tenantSuggestions.length > 0 ? (
|
||||||
</div>
|
tenantSuggestions.map((tenant) => (
|
||||||
<p className="text-xs text-muted-foreground">
|
<button
|
||||||
{tenant.description || tenant.type}
|
key={tenant.id}
|
||||||
</p>
|
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>
|
</div>
|
||||||
<input
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
type="checkbox"
|
{tenant.description || tenant.type}
|
||||||
checked={checked}
|
</p>
|
||||||
onChange={() => toggleAllowedTenant(tenant.id)}
|
</div>
|
||||||
className="h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
/>
|
</button>
|
||||||
</label>
|
))
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -1287,17 +1320,21 @@ function ClientGeneralPage() {
|
|||||||
"검색 결과가 없습니다.",
|
"검색 결과가 없습니다.",
|
||||||
)}
|
)}
|
||||||
</div>
|
</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",
|
||||||
|
"테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.",
|
||||||
)
|
)
|
||||||
) : (
|
: t(
|
||||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
"ui.dev.clients.general.tenant_access.disabled",
|
||||||
{t(
|
"제한 없음",
|
||||||
"ui.dev.clients.general.tenant_access.disabled",
|
)}
|
||||||
"제한 없음",
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
|
||||||
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
|
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{allowedTenantIds.map((tenantId) => {
|
{selectedAllowedTenants.map((tenant) => (
|
||||||
const tenant = tenantData?.items.find(
|
<Badge
|
||||||
(item) => item.id === tenantId,
|
key={tenant.id}
|
||||||
);
|
variant="secondary"
|
||||||
return (
|
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
|
<Badge
|
||||||
key={tenantId}
|
key={tenantId}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="gap-2 px-3 py-1.5"
|
className="gap-2 px-3 py-1.5"
|
||||||
>
|
>
|
||||||
<span className="max-w-44 truncate">
|
<Check className="h-3.5 w-3.5" />
|
||||||
{tenant?.name || tenantId}
|
<span className="max-w-44 truncate">{tenantId}</span>
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
@@ -1330,13 +1393,12 @@ function ClientGeneralPage() {
|
|||||||
"삭제",
|
"삭제",
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleAllowedTenant(tenantId)}
|
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>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
|
<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."
|
selected_empty = "No tenants selected yet."
|
||||||
empty = "No tenants match your search."
|
empty = "No tenants match your search."
|
||||||
hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant."
|
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."
|
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
|
|||||||
@@ -1448,6 +1448,7 @@ selected_title = "허용 테넌트"
|
|||||||
selected_empty = "아직 선택된 테넌트가 없습니다."
|
selected_empty = "아직 선택된 테넌트가 없습니다."
|
||||||
empty = "검색 결과가 없습니다."
|
empty = "검색 결과가 없습니다."
|
||||||
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
|
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||||
|
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
|
||||||
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
|
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
|
|||||||
@@ -1531,6 +1531,7 @@ selected_title = ""
|
|||||||
selected_empty = ""
|
selected_empty = ""
|
||||||
empty = ""
|
empty = ""
|
||||||
hint = ""
|
hint = ""
|
||||||
|
autocomplete_hint = ""
|
||||||
validation_required = ""
|
validation_required = ""
|
||||||
|
|
||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
|
|||||||
Reference in New Issue
Block a user