forked from baron/baron-sso
검색/필터 바 shell 공통화
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
} from "../../../../common/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -426,47 +427,53 @@ function UserListPage() {
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(e) => {
|
||||
setSelectedCompany(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(e) => {
|
||||
setSelectedCompany(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={profile?.role === "tenant_admin"}
|
||||
>
|
||||
<option value="">
|
||||
{t("ui.common.all", "전체 테넌트")}
|
||||
</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.slug}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import type { DevAuditLog } from "../../lib/devApi";
|
||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||
@@ -227,49 +228,55 @@ function AuditLogsPage() {
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) =>
|
||||
setSearchAction(e.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sortableTableHeadBaseClassName,
|
||||
sortableTableHeaderClassName,
|
||||
} from "../../../../common/core/components/sort";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { PageHeader } from "../../../../common/core/components/page";
|
||||
import {
|
||||
type SortConfig,
|
||||
@@ -283,8 +284,8 @@ function ClientsPage() {
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardHeader className="pb-4 pt-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -297,7 +298,9 @@ function ClientsPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -324,65 +327,67 @@ function ClientsPage() {
|
||||
{t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
|
||||
</Badge>
|
||||
</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)}
|
||||
</>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<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 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
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 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
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="ghost"
|
||||
size="sm"
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</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="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground ml-auto"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import type { DevAuditLog } from "../../lib/devApi";
|
||||
import { fetchDevAuditLogs } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
@@ -227,43 +228,55 @@ function AuditLogsPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 md:grid-cols-[1fr,1fr,180px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
query.refetch();
|
||||
}}
|
||||
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
value={searchClientId}
|
||||
onChange={(e) => setSearchClientId(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.client_id",
|
||||
"Filter by Client ID",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={searchAction}
|
||||
onChange={(e) =>
|
||||
setSearchAction(e.target.value.toUpperCase())
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.dev.audit.filter.action",
|
||||
"Filter by Action (e.g. ROTATE_SECRET)",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">
|
||||
{t("ui.dev.audit.filter.status_all", "All Status")}
|
||||
</option>
|
||||
<option value="success">
|
||||
{t("ui.common.status.success", "Success")}
|
||||
</option>
|
||||
<option value="failure">
|
||||
{t("ui.common.status.failure", "Failure")}
|
||||
</option>
|
||||
</select>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -235,8 +236,8 @@ function ClientConsentsPage() {
|
||||
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4 flex-1">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -249,126 +250,128 @@ function ClientConsentsPage() {
|
||||
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",
|
||||
)}
|
||||
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={() => setSubject(subjectInput.trim())}
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdvancedFilterOpen && (
|
||||
<div className="flex flex-col gap-4 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 flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("active")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("active", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("revoked")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("revoked", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.scope_label", "Scope:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{allScopes.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={
|
||||
scopeFilter.length === allScopes.length &&
|
||||
allScopes.length > 0
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleAllScopesChange(e.target.checked)
|
||||
}
|
||||
/>
|
||||
ALL
|
||||
</label>
|
||||
)}
|
||||
{allScopes.map((scope) => (
|
||||
<label
|
||||
key={scope}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={scopeFilter.includes(scope)}
|
||||
onChange={(e) =>
|
||||
handleScopeFilterChange(scope, e.target.checked)
|
||||
}
|
||||
/>
|
||||
{scope}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground p-0 h-auto"
|
||||
onClick={() => {
|
||||
setStatusFilter([]);
|
||||
setScopeFilter([]);
|
||||
}}
|
||||
className={cn(
|
||||
"gap-1 text-muted-foreground",
|
||||
isAdvancedFilterOpen && "text-primary bg-primary/10",
|
||||
)}
|
||||
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
<Filter className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.consents.filters.advanced",
|
||||
"Advanced Filters",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={() => setSubject(subjectInput.trim())}
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
<Button
|
||||
className="shadow-sm shadow-primary/30"
|
||||
onClick={handleExportCSV}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.status_label", "Status:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("active")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("active", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.common.status.active", "Active")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={statusFilter.includes("revoked")}
|
||||
onChange={(e) =>
|
||||
handleStatusFilterChange("revoked", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{t("ui.dev.clients.consents.scope_label", "Scope:")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{allScopes.length > 0 && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={
|
||||
scopeFilter.length === allScopes.length &&
|
||||
allScopes.length > 0
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleAllScopesChange(e.target.checked)
|
||||
}
|
||||
/>
|
||||
ALL
|
||||
</label>
|
||||
)}
|
||||
{allScopes.map((scope) => (
|
||||
<label
|
||||
key={scope}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
|
||||
checked={scopeFilter.includes(scope)}
|
||||
onChange={(e) =>
|
||||
handleScopeFilterChange(scope, e.target.checked)
|
||||
}
|
||||
/>
|
||||
{scope}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground p-0 h-auto"
|
||||
onClick={() => {
|
||||
setStatusFilter([]);
|
||||
setScopeFilter([]);
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { fetchClients, fetchDevStats } from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -179,8 +180,9 @@ function ClientsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<SearchFilterBar
|
||||
className="mt-4"
|
||||
primary={
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -193,7 +195,9 @@ function ClientsPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -220,65 +224,67 @@ function ClientsPage() {
|
||||
{t("ui.dev.clients.badge.admin_session", "관리자 세션")}
|
||||
</Badge>
|
||||
</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)}
|
||||
</>
|
||||
}
|
||||
advancedOpen={isAdvancedFilterOpen}
|
||||
advanced={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<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 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
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 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
|
||||
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="ghost"
|
||||
size="sm"
|
||||
className="ml-auto text-xs text-muted-foreground"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</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="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground ml-auto"
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setStatusFilter("all");
|
||||
}}
|
||||
>
|
||||
{t("ui.common.reset", "초기화")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
|
||||
Reference in New Issue
Block a user