1
0
forked from baron/baron-sso

연동 앱 및 Consent 목록 상세 필터 기능 추가

This commit is contained in:
2026-02-26 13:24:50 +09:00
parent c8f39c15e0
commit d60bc1d5d5
3 changed files with 224 additions and 77 deletions

View File

@@ -27,6 +27,7 @@ import {
} from "../../components/ui/table"; } from "../../components/ui/table";
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi"; import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
function ClientConsentsPage() { function ClientConsentsPage() {
const params = useParams(); const params = useParams();
@@ -34,6 +35,8 @@ function ClientConsentsPage() {
const [subjectInput, setSubjectInput] = useState(""); const [subjectInput, setSubjectInput] = useState("");
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [scopeFilter, setScopeFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const { data: clientData } = useQuery({ const { data: clientData } = useQuery({
queryKey: ["client", clientId], queryKey: ["client", clientId],
@@ -72,6 +75,10 @@ function ClientConsentsPage() {
}; };
const rows = consentsData?.items ?? []; const rows = consentsData?.items ?? [];
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
const filteredRows = rows.filter((row) => {
return scopeFilter === "all" || row.grantedScopes.includes(scopeFilter);
});
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -147,59 +154,105 @@ function ClientConsentsPage() {
</header> </header>
<Card className="glass-panel"> <Card className="glass-panel">
<CardContent className="flex flex-wrap items-center justify-between gap-4"> <CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-4 flex-1"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="relative w-full max-w-md"> <div className="flex flex-wrap items-center gap-4 flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <div className="relative w-full max-w-md">
<Input <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
className="pl-10" <Input
placeholder={t( className="pl-10"
"ui.dev.clients.consents.search_placeholder", placeholder={t(
"사용자 ID, 이름, 이메일로 검색", "ui.dev.clients.consents.search_placeholder",
"사용자 ID, 이름, 이메일로 검색",
)}
value={subjectInput}
onChange={(e) => setSubjectInput(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="ghost"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)} )}
value={subjectInput} onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
onChange={(e) => setSubjectInput(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<select
className="h-10 rounded-lg border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
> >
<option value="all"> <Filter className="h-4 w-4" />
{t("ui.dev.clients.consents.status_all", "All Statuses")} {t(
</option> "ui.dev.clients.consents.filters.advanced",
<option value="active"> "Advanced Filters",
{t("ui.common.status.active", "Active")} )}
</option> </Button>
<option value="revoked"> <Button
{t("ui.dev.clients.consents.status_revoked", "Revoked")} className="shadow-sm shadow-primary/30"
</option> onClick={() => setSubject(subjectInput.trim())}
</select> >
{t("ui.common.search", "검색")}
</Button>
<Button className="shadow-sm shadow-primary/30">
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
</Button>
</div> </div>
</div> </div>
<div className="flex items-center gap-3">
<Button variant="ghost" className="gap-1 text-muted-foreground"> {isAdvancedFilterOpen && (
<Filter className="h-4 w-4" /> <div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
{t( <div className="flex items-center gap-2">
"ui.dev.clients.consents.filters.advanced", <span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
"Advanced Filters", {t("ui.dev.clients.consents.status_label", "Status:")}
)} </span>
</Button> <select
<Button className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
className="shadow-sm shadow-primary/30" value={statusFilter}
onClick={() => setSubject(subjectInput.trim())} onChange={(e) => setStatusFilter(e.target.value)}
> >
{t("ui.common.search", "검색")} <option value="all">
</Button> {t("ui.dev.clients.consents.status_all", "All Statuses")}
<Button className="shadow-sm shadow-primary/30"> </option>
{t("ui.dev.clients.consents.export_csv", "Export CSV")} <option value="active">
</Button> {t("ui.common.status.active", "Active")}
</div> </option>
<option value="revoked">
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.consents.scope_label", "Scope:")}
</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.consents.scope_all", "All Scopes")}
</option>
{allScopes.map((scope) => (
<option key={scope} value={scope}>
{scope}
</option>
))}
</select>
</div>
<Button
variant="link"
size="sm"
className="text-xs text-muted-foreground ml-auto"
onClick={() => {
setStatusFilter("all");
setScopeFilter("all");
}}
>
{t("ui.common.reset", "초기화")}
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -254,14 +307,14 @@ function ClientConsentsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{rows.length === 0 && !isLoading ? ( {filteredRows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="h-24 text-center"> <TableCell colSpan={7} className="h-24 text-center">
{t("msg.dev.clients.consents.empty", "No consents found.")} {t("msg.dev.clients.consents.empty", "No consents found.")}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row) => ( filteredRows.map((row) => (
<TableRow <TableRow
key={`${row.subject}-${row.clientId}`} key={`${row.subject}-${row.clientId}`}
className={row.status === "revoked" ? "opacity-60" : ""} className={row.status === "revoked" ? "opacity-60" : ""}
@@ -356,8 +409,8 @@ function ClientConsentsPage() {
"msg.dev.clients.consents.showing", "msg.dev.clients.consents.showing",
"Showing {{from}} to {{to}} of {{total}} users", "Showing {{from}} to {{to}} of {{total}} users",
{ {
from: rows.length > 0 ? 1 : 0, from: filteredRows.length > 0 ? 1 : 0,
to: rows.length, to: filteredRows.length,
total: rows.length, total: rows.length,
}, },
)} )}
@@ -366,7 +419,7 @@ function ClientConsentsPage() {
<Button variant="outline" size="icon" disabled> <Button variant="outline" size="icon" disabled>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<Button size="sm" disabled={rows.length === 0}> <Button size="sm" disabled={filteredRows.length === 0}>
1 1
</Button> </Button>
<Button variant="outline" size="icon" disabled> <Button variant="outline" size="icon" disabled>
@@ -402,8 +455,6 @@ function ClientConsentsPage() {
{rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)} {rows.reduce((acc, row) => acc + row.grantedScopes.length, 0)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
</Card>
<Card className="glass-panel">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground"> <p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t( {t(

View File

@@ -2,11 +2,13 @@ import { useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
BookOpenText, BookOpenText,
Filter,
Plus, Plus,
Search, Search,
ServerCog, ServerCog,
ShieldHalf, ShieldHalf,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { import {
Avatar, Avatar,
@@ -43,7 +45,24 @@ function ClientsPage() {
queryFn: fetchClients, queryFn: fetchClients,
}); });
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const clients = data?.items || []; const clients = data?.items || [];
const filteredClients = clients.filter((client) => {
const matchesSearch =
!searchQuery ||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = typeFilter === "all" || client.type === typeFilter;
const matchesStatus =
statusFilter === "all" || client.status === statusFilter;
return matchesSearch && matchesType && matchesStatus;
});
const totalClients = clients.length; const totalClients = clients.length;
const activeClients = clients.filter( const activeClients = clients.filter(
(client) => client.status === "active", (client) => client.status === "active",
@@ -137,25 +156,102 @@ function ClientsPage() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="mt-4 grid gap-3 md:grid-cols-[1.5fr,1fr]"> <div className="mt-4 flex flex-col gap-3">
<div className="relative"> <div className="flex flex-col gap-3 md:flex-row md:items-center">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <div className="relative flex-1">
<Input <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
className="pl-10" <Input
placeholder={t( className="pl-10"
"ui.dev.clients.search_placeholder", placeholder={t(
"클라이언트 이름/ID로 검색...", "ui.dev.clients.search_placeholder",
)} "클라이언트 이름/ID로 검색...",
/> )}
</div> value={searchQuery}
<div className="flex items-center justify-end gap-2 md:justify-start"> onChange={(e) => setSearchQuery(e.target.value)}
<Badge variant="muted"> />
{t("ui.dev.clients.badge.tenant_selected", "테넌트: 선택됨")} </div>
</Badge> <div className="flex items-center gap-2">
<Badge variant="success"> <Button
{t("ui.dev.clients.badge.admin_session", "관리자 세션")} variant="ghost"
</Badge> size="sm"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)}
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
>
<Filter className="h-4 w-4" />
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
<div className="hidden items-center gap-2 md:flex">
<Badge variant="muted">
{t("ui.dev.clients.badge.tenant_selected", "테넌트: 선택됨")}
</Badge>
<Badge variant="success">
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
</Badge>
</div>
</div>
</div> </div>
{isAdvancedFilterOpen && (
<div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.filter.type_label", "Type:")}
</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.filter.type_all", "모든 유형")}
</option>
<option value="private">
{t("ui.dev.clients.type.private", "Server side App")}
</option>
<option value="pkce">
{t("ui.dev.clients.type.pkce", "PKCE")}
</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.filter.status_all", "모든 상태")}
</option>
<option value="active">
{t("ui.common.status.active", "Active")}
</option>
<option value="inactive">
{t("ui.common.status.inactive", "Inactive")}
</option>
</select>
</div>
<Button
variant="link"
size="sm"
className="text-xs text-muted-foreground ml-auto"
onClick={() => {
setTypeFilter("all");
setStatusFilter("all");
}}
>
{t("ui.common.reset", "초기화")}
</Button>
</div>
)}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
@@ -222,7 +318,7 @@ function ClientsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{clients.map((client) => ( {filteredClients.map((client) => (
<TableRow key={client.id} className="bg-card/40"> <TableRow key={client.id} className="bg-card/40">
<TableCell> <TableCell>
<Link <Link
@@ -296,7 +392,7 @@ function ClientsPage() {
{t( {t(
"msg.dev.clients.showing", "msg.dev.clients.showing",
"Showing {{shown}} of {{total}} clients", "Showing {{shown}} of {{total}} clients",
{ shown: clients.length, total: totalClients }, { shown: filteredClients.length, total: totalClients },
)} )}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, Edit, Globe, Save } from "lucide-react"; import { Edit, Globe, Plus, Save, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";