feat(users, api-keys): add consistent search filters

This commit is contained in:
KodekaTeam committed 2026-08-05 12:43:30 +07:00
1 parent fd4585843f
commit 626cadbde6
7 files changed
+107 -80

No files matched your search

+18 -14
View File
@@ -3,6 +3,7 @@
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
import GuardedPage from "@/components/GuardedPage";
import IssueDetailsSummary from "@/components/IssueDetailsSummary";
import SearchField from "@/components/SearchField";
import {
apiKeys as apiKeysApi,
type ApiKeyExpiryOption,
@@ -156,6 +157,7 @@ function maskKey(prefix: string) {
export default function ApiKeysPage() {
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
@@ -186,6 +188,11 @@ export default function ApiKeysPage() {
return { active, revoked, totalRequests, expiringSoon };
}, [keys]);
const filteredKeys = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return keys;
return keys.filter((key) => [key.name, key.keyPrefix, key.isActive ? "active" : "revoked", ...key.permissions].some((value) => value.toLowerCase().includes(query)));
}, [keys, search]);
useEffect(() => {
void loadKeys();
@@ -490,20 +497,12 @@ export default function ApiKeysPage() {
</div>
<div
className="card"
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
className="card ui-responsive-toolbar"
style={{ padding: "12px 16px" }}
>
<p style={{ fontSize: 13, color: "var(--text-muted)" }}>
{keys.length} API keys total
</p>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<SearchField placeholder="Search API keys, prefixes, or permissions..." value={search} onChange={(event) => setSearch(event.target.value)} containerStyle={{ flex: "1 1 360px", minWidth: 240 }} />
<div className="ui-toolbar-actions">
<span style={{ fontSize: 13, color: "var(--text-muted)" }}>{keys.length} API keys total</span>
<button
className="btn btn-ghost"
style={{ fontSize: 12 }}
@@ -577,9 +576,14 @@ export default function ApiKeysPage() {
call this panel without logging in through the browser.
</p>
</div>
) : filteredKeys.length === 0 ? (
<div className="card" style={{ padding: 24, textAlign: "center", color: "var(--text-secondary)" }}>
<Key size={20} style={{ marginBottom: 10, color: "#3b82f6" }} />
<p style={{ fontSize: 14, fontWeight: 700, color: "var(--text-primary)" }}>No API keys match your search</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{keys.map((key) => {
{filteredKeys.map((key) => {
const canReveal = Boolean(sessionRawKeys[key.id]);
const isRevealed = revealedKeyId === key.id && canReveal;
const rawValue = sessionRawKeys[key.id];
+20
View File
@@ -378,6 +378,12 @@ body {
flex-wrap: wrap;
}
.users-toolbar-filter {
flex: 0 1 200px;
width: min(200px, 100%);
align-self: flex-start;
}
.ui-toolbar-note {
margin-left: auto;
text-align: right;
@@ -427,6 +433,20 @@ body {
justify-content: center;
}
.users-toolbar .ui-toolbar-actions {
flex-direction: column;
}
.users-toolbar .ui-toolbar-actions > button {
width: 100%;
flex-basis: auto;
}
.users-toolbar-filter {
flex-basis: 100%;
width: 100%;
}
.ui-pill-switch,
.ui-tab-scroll {
width: 100%;
@@ -20,6 +20,7 @@ interface ActiveUsersPanelProps {
totalItems: number;
startItem: number;
endItem: number;
emptyMessage: string;
onPageChange: (page: number) => void;
onEditAccess: (user: UserRecord) => void;
onEditRole: (user: UserRecord) => void;
@@ -38,6 +39,7 @@ export default function ActiveUsersPanel({
totalItems,
startItem,
endItem,
emptyMessage,
onPageChange,
onEditAccess,
onEditRole,
@@ -69,7 +71,7 @@ export default function ActiveUsersPanel({
fontSize: 13,
}}
>
No active users found.
{emptyMessage}
</div>
) : (
<>
@@ -16,6 +16,7 @@ interface ArchivedUsersPanelProps {
totalItems: number;
startItem: number;
endItem: number;
emptyMessage: string;
onPageChange: (page: number) => void;
}
@@ -27,6 +28,7 @@ export default function ArchivedUsersPanel({
totalItems,
startItem,
endItem,
emptyMessage,
onPageChange,
}: ArchivedUsersPanelProps) {
return (
@@ -77,7 +79,7 @@ export default function ArchivedUsersPanel({
fontSize: 13,
}}
>
No archived users.
{emptyMessage}
</div>
) : (
<>
@@ -17,6 +17,7 @@ interface PendingInvitesPanelProps {
totalItems: number;
startItem: number;
endItem: number;
emptyMessage: string;
onPageChange: (page: number) => void;
onCopyFreshInviteLink: (invitationId: string) => void | Promise<void>;
onRevokeInvitation: (invitationId: string) => void | Promise<void>;
@@ -32,6 +33,7 @@ export default function PendingInvitesPanel({
totalItems,
startItem,
endItem,
emptyMessage,
onPageChange,
onCopyFreshInviteLink,
onRevokeInvitation,
@@ -83,7 +85,7 @@ export default function PendingInvitesPanel({
fontSize: 13,
}}
>
No pending invitations.
{emptyMessage}
</div>
) : (
<>
+30 -57
View File
@@ -1,9 +1,11 @@
import { Clock, Loader2, Lock, Plus, RefreshCw, Users } from "lucide-react";
import SearchField from "@/components/SearchField";
import { Loader2, Plus, RefreshCw } from "lucide-react";
export type UsersTabKey = "active-users" | "pending-invites" | "archived-users";
interface UsersToolbarProps {
activeTab: UsersTabKey;
search: string;
activeUsersCount: number;
invitationsCount: number;
archivedUsersCount: number;
@@ -11,12 +13,14 @@ interface UsersToolbarProps {
refreshing: boolean;
canInvite: boolean;
onTabChange: (tab: UsersTabKey) => void;
onSearchChange: (value: string) => void;
onRefresh: () => void | Promise<void>;
onInvite: () => void;
}
export default function UsersToolbar({
activeTab,
search,
activeUsersCount,
invitationsCount,
archivedUsersCount,
@@ -24,81 +28,50 @@ export default function UsersToolbar({
refreshing,
canInvite,
onTabChange,
onSearchChange,
onRefresh,
onInvite,
}: UsersToolbarProps) {
const tabs = [
const tabs: Array<{ key: UsersTabKey; label: string }> = [
{
key: "active-users" as const,
key: "active-users",
label: `Active Users (${activeUsersCount})`,
icon: Users,
},
{
key: "pending-invites" as const,
key: "pending-invites",
label: `Pending Invites (${invitationsCount})`,
icon: Clock,
},
{
key: "archived-users" as const,
key: "archived-users",
label: `Archived Users (${archivedUsersCount})`,
icon: Lock,
},
];
return (
<div
className="card"
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
className="card ui-responsive-toolbar users-toolbar"
style={{ padding: "12px 16px" }}
>
<div
style={{
display: "flex",
gap: 4,
background: "var(--bg-card)",
borderRadius: 10,
padding: 4,
border: "1px solid var(--border)",
}}
<SearchField
placeholder="Search users, roles, emails, or servers..."
value={search}
onChange={(event) => onSearchChange(event.target.value)}
containerStyle={{ flex: "1 1 360px", minWidth: 240 }}
/>
<select
className="input users-toolbar-filter"
aria-label="Filter user records"
value={activeTab}
onChange={(event) => onTabChange(event.target.value as UsersTabKey)}
style={{ fontSize: 12 }}
>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "7px 16px",
borderRadius: 7,
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
background:
activeTab === tab.key
? "rgba(59,130,246,0.15)"
: "transparent",
color: activeTab === tab.key ? "#3b82f6" : "var(--text-muted)",
transition: "all 0.2s",
}}
>
<Icon size={13} />
{tabs.map((tab) => (
<option key={tab.key} value={tab.key}>
{tab.label}
</button>
);
})}
</div>
<div style={{ display: "flex", gap: 8 }}>
</option>
))}
</select>
<div className="ui-toolbar-actions">
<button
className="btn btn-ghost"
style={{ fontSize: 12 }}
+30 -6
View File
@@ -49,6 +49,7 @@ export default function UsersPage() {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState<UsersTabKey>("active-users");
const [search, setSearch] = useState("");
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteError, setInviteError] = useState("");
const [inviteResult, setInviteResult] = useState<{
@@ -81,17 +82,35 @@ export default function UsersPage() {
() => users.filter((user) => !user.isActive),
[users],
);
const filteredActiveUsers = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return activeUsers;
return activeUsers.filter((user) =>
[user.name, user.email, user.role, ...user.serverAssignments.map((assignment) => assignment.server.name)].some((value) => value.toLowerCase().includes(query)),
);
}, [activeUsers, search]);
const filteredInvitations = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return invitations;
return invitations.filter((invitation) => [invitation.name, invitation.email, invitation.role, ...invitation.servers.map((server) => server.name)].some((value) => value.toLowerCase().includes(query)));
}, [invitations, search]);
const filteredArchivedUsers = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return archivedUsers;
return archivedUsers.filter((user) => [user.name, user.email, user.role, ...user.serverAssignments.map((assignment) => assignment.server.name)].some((value) => value.toLowerCase().includes(query)));
}, [archivedUsers, search]);
const usersPagination = useTablePagination({
items: activeUsers,
resetKey: `${activeUsers.length}|${activeTab}`,
items: filteredActiveUsers,
resetKey: `${filteredActiveUsers.length}|${activeTab}|${search}`,
});
const invitationsPagination = useTablePagination({
items: invitations,
resetKey: `${invitations.length}|${activeTab}`,
items: filteredInvitations,
resetKey: `${filteredInvitations.length}|${activeTab}|${search}`,
});
const archivedUsersPagination = useTablePagination({
items: archivedUsers,
resetKey: `${archivedUsers.length}|${activeTab}`,
items: filteredArchivedUsers,
resetKey: `${filteredArchivedUsers.length}|${activeTab}|${search}`,
});
const loadData = useCallback(
@@ -394,6 +413,7 @@ export default function UsersPage() {
<UsersToolbar
activeTab={activeTab}
search={search}
activeUsersCount={activeUsers.length}
invitationsCount={invitations.length}
archivedUsersCount={archivedUsers.length}
@@ -401,6 +421,7 @@ export default function UsersPage() {
refreshing={refreshing}
canInvite={canInvite}
onTabChange={setActiveTab}
onSearchChange={setSearch}
onRefresh={handleRefresh}
onInvite={() => {
setInviteError("");
@@ -422,6 +443,7 @@ export default function UsersPage() {
totalItems={usersPagination.totalItems}
startItem={usersPagination.startItem}
endItem={usersPagination.endItem}
emptyMessage={search.trim() ? "No active users match your search." : "No active users found."}
onPageChange={usersPagination.setCurrentPage}
onEditAccess={(user) => {
setModalError("");
@@ -446,6 +468,7 @@ export default function UsersPage() {
totalItems={invitationsPagination.totalItems}
startItem={invitationsPagination.startItem}
endItem={invitationsPagination.endItem}
emptyMessage={search.trim() ? "No pending invitations match your search." : "No pending invitations."}
onPageChange={invitationsPagination.setCurrentPage}
onCopyFreshInviteLink={handleCopyFreshInviteLink}
onRevokeInvitation={handleRevokeInvitation}
@@ -461,6 +484,7 @@ export default function UsersPage() {
totalItems={archivedUsersPagination.totalItems}
startItem={archivedUsersPagination.startItem}
endItem={archivedUsersPagination.endItem}
emptyMessage={search.trim() ? "No archived users match your search." : "No archived users."}
onPageChange={archivedUsersPagination.setCurrentPage}
/>
) : null}