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