feat(users, api-keys): add consistent search filters
This commit is contained in:
1 parent
fd4585843f
commit
626cadbde6
7 files changed
+107
-80
No files matched your search
+18
-14
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user