Skip to content

Commit b484ce4

Browse files
committed
frontend/tokens: add search
1 parent b4c00c2 commit b484ce4

File tree

4 files changed

+158
-19
lines changed

4 files changed

+158
-19
lines changed

frontend/src/locales/de.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ export const de ={
233233
"sort_name_asc": "Name A-Z",
234234
"sort_name_desc": "Name Z-A",
235235
"sort_last_used_desc": "Kürzlich verwendet",
236+
"search_label": "Token suchen",
237+
"search_placeholder": "Nach Name suchen...",
238+
"no_tokens": "Noch keine Token vorhanden. Erstelle oben ein Token, um zu starten.",
239+
"no_tokens_search": "Keine Token entsprechen deiner Suche.",
236240
firmware_needed
237241
},
238242
"wg_client": {

frontend/src/locales/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,10 @@ export const en = {
233233
"sort_name_asc": "Name A-Z",
234234
"sort_name_desc": "Name Z-A",
235235
"sort_last_used_desc": "Recently used",
236+
"search_label": "Search tokens",
237+
"search_placeholder": "Search by name",
238+
"no_tokens": "No authorization tokens yet. Create one above to get started.",
239+
"no_tokens_search": "No tokens match your search.",
236240
firmware_needed
237241
},
238242
"wg_client": {

frontend/src/pages/Tokens.tsx

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function Tokens() {
7676
const [user, setUser] = useState<components["schemas"]["UserInfo"] | null>(null);
7777
const [loading, setLoading] = useState(true);
7878
const [sortOption, setSortOption] = useState<SortOption>('created-desc');
79+
const [searchQuery, setSearchQuery] = useState("");
7980

8081
const sortedTokens = useMemo(() => {
8182
const copy = [...tokens];
@@ -100,6 +101,18 @@ export function Tokens() {
100101
return copy;
101102
}, [tokens, sortOption]);
102103

104+
const filteredTokens = useMemo(() => {
105+
const normalizedQuery = searchQuery.trim().toLowerCase();
106+
if (!normalizedQuery) {
107+
return sortedTokens;
108+
}
109+
110+
return sortedTokens.filter((token) => {
111+
const nameMatch = token.name.toLowerCase().includes(normalizedQuery);
112+
return nameMatch;
113+
});
114+
}, [searchQuery, sortedTokens]);
115+
103116
// Fetch tokens and user data from the server on component mount
104117
useEffect(() => {
105118
async function fetchTokens() {
@@ -292,30 +305,42 @@ export function Tokens() {
292305
</Form>
293306
</Card.Body>
294307
<Card.Header className="border-top pb-2">
295-
<div className="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3">
296-
<h5 className="mb-0">{t("tokens.existing_tokens")}</h5>
297-
<Form.Select
298-
aria-label={t("tokens.sort_label")}
299-
className="w-auto"
300-
value={sortOption}
301-
onChange={(e) => setSortOption((e.target as HTMLSelectElement).value as SortOption)}
302-
>
303-
{SORT_OPTIONS.map((option) => (
304-
<option key={option.value} value={option.value}>
305-
{t(option.labelKey)}
306-
</option>
307-
))}
308-
</Form.Select>
308+
<div className="d-flex flex-column gap-3">
309+
<div className="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-3">
310+
<h5 className="mb-0">{t("tokens.existing_tokens")}</h5>
311+
<Form.Select
312+
className="w-auto"
313+
value={sortOption}
314+
onChange={(e) => setSortOption((e.target as HTMLSelectElement).value as SortOption)}
315+
>
316+
{SORT_OPTIONS.map((option) => (
317+
<option key={option.value} value={option.value}>
318+
{t(option.labelKey)}
319+
</option>
320+
))}
321+
</Form.Select>
322+
</div>
323+
<Form.Control
324+
type="search"
325+
className="mb-2"
326+
placeholder={t("tokens.search_placeholder")}
327+
value={searchQuery}
328+
onChange={(e) => setSearchQuery((e.target as HTMLInputElement).value)}
329+
/>
309330
</div>
310331
</Card.Header>
311332
<Card.Body>
312-
{sortedTokens.map((token, index) => {
333+
{filteredTokens.length === 0 ? (
334+
<p className="text-muted fst-italic mb-0">
335+
{searchQuery.trim() ? t("tokens.no_tokens_search") : t("tokens.no_tokens")}
336+
</p>
337+
) : filteredTokens.map((token, index) => {
313338
const isUsed = token.use_once && token.lastUsedAt !== null;
314339
const statusVariant = isUsed ? "danger" : (token.use_once ? "success" : "warning");
315340
const statusText = isUsed ? t("tokens.used") : (token.use_once ? t("tokens.use_once") : t("tokens.reusable"));
316341

317342
return (
318-
<div key={token.id} className={`token-item ${index !== sortedTokens.length - 1 ? 'mb-4' : ''}`}>
343+
<div key={token.id} className={`token-item ${index !== filteredTokens.length - 1 ? 'mb-4' : ''}`}>
319344
<div className="d-flex justify-content-between align-items-start mb-2">
320345
<div>
321346
<h6 className={`mb-1 fw-bold ${isUsed ? 'text-muted' : ''}`}>{token.name}</h6>
@@ -373,7 +398,7 @@ export function Tokens() {
373398
{t("tokens.delete")}
374399
</Button>
375400
</div>
376-
{index !== sortedTokens.length - 1 && <hr className="mt-3" />}
401+
{index !== filteredTokens.length - 1 && <hr className="mt-3" />}
377402
</div>
378403
);
379404
})}

frontend/src/pages/__tests__/Tokens.test.tsx

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { render, screen, waitFor, fireEvent } from '@testing-library/preact';
2-
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
1+
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/preact';
2+
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
33

44
vi.mock('argon2-browser', () => ({
55
hash: vi.fn(async () => ({ hash: new Uint8Array([1, 2, 3]) })),
@@ -26,17 +26,52 @@ describe('Tokens Component', () => {
2626

2727
let fetchClient: typeof import('../../utils').fetchClient;
2828
let showAlert: Mock;
29+
let restoreIntervalMocks: (() => void) | null = null;
2930

3031
beforeEach(async () => {
3132
vi.clearAllMocks();
3233

34+
const originalSetInterval = globalThis.setInterval;
35+
const originalClearInterval = globalThis.clearInterval;
36+
globalThis.setInterval = ((handler: (...args: unknown[]) => void) => {
37+
return { __fake: 'interval' } as unknown as NodeJS.Timeout;
38+
}) as typeof setInterval;
39+
globalThis.clearInterval = (() => undefined) as typeof clearInterval;
40+
restoreIntervalMocks = () => {
41+
globalThis.setInterval = originalSetInterval;
42+
globalThis.clearInterval = originalClearInterval;
43+
};
44+
3345
const utils = await import('../../utils');
3446
fetchClient = utils.fetchClient;
47+
(fetchClient.GET as unknown as Mock).mockImplementation((path: string) => {
48+
if (path === '/user/me') {
49+
return Promise.resolve({
50+
data: mockUserData,
51+
error: null,
52+
response: { status: 200 },
53+
});
54+
}
55+
if (path === '/user/get_authorization_tokens') {
56+
return Promise.resolve({
57+
data: { tokens: [] },
58+
error: null,
59+
response: { status: 200 },
60+
});
61+
}
62+
return Promise.resolve({ data: null, error: 'Not found', response: { status: 404 } });
63+
});
3564

3665
const alertModule = await import('../../components/Alert');
3766
showAlert = alertModule.showAlert as unknown as Mock;
3867
});
3968

69+
afterEach(() => {
70+
cleanup();
71+
restoreIntervalMocks?.();
72+
restoreIntervalMocks = null;
73+
});
74+
4075
it('renders loading spinner initially', () => {
4176
render(<Tokens />);
4277
expect(screen.getByText('Loading...')).toBeTruthy();
@@ -161,4 +196,75 @@ describe('Tokens Component', () => {
161196
expect(getTokenNames()).toEqual(['Charlie', 'Bravo', 'Alpha']);
162197
});
163198
});
199+
200+
it('filters tokens using the search field', async () => {
201+
const base64 = Base64 as unknown as { toUint8Array: Mock };
202+
const encoder = new TextEncoder();
203+
base64.toUint8Array.mockImplementation((value: string) => encoder.encode(value));
204+
205+
const libsodium = sodium as unknown as { crypto_box_seal_open: Mock };
206+
libsodium.crypto_box_seal_open.mockImplementation((binary: Uint8Array) => binary);
207+
208+
(fetchClient.GET as unknown as Mock).mockImplementation((path: string) => {
209+
if (path === '/user/me') {
210+
return Promise.resolve({
211+
data: mockUserData,
212+
error: null,
213+
response: { status: 200 },
214+
});
215+
}
216+
if (path === '/user/get_authorization_tokens') {
217+
return Promise.resolve({
218+
data: {
219+
tokens: [
220+
{ token: 'token-alpha', use_once: false, id: '1', name: 'Alpha', created_at: 1_000, last_used_at: null },
221+
{ token: 'token-bravo', use_once: false, id: '2', name: 'Bravo', created_at: 2_000, last_used_at: 1_900 },
222+
{ token: 'token-charlie', use_once: false, id: '3', name: 'Charlie', created_at: 1_500, last_used_at: 1_950 },
223+
],
224+
},
225+
error: null,
226+
response: { status: 200 },
227+
});
228+
}
229+
return Promise.resolve({ data: null, error: 'Not found', response: { status: 404 } });
230+
});
231+
232+
render(<Tokens />);
233+
234+
await waitFor(() => {
235+
expect(screen.getByLabelText('tokens.search_label')).toBeTruthy();
236+
});
237+
238+
const getTokenNames = () => screen
239+
.queryAllByRole('heading', { level: 6 })
240+
.map((heading) => heading.textContent?.trim())
241+
.filter(Boolean);
242+
243+
await waitFor(() => {
244+
expect(getTokenNames()).toEqual(['Bravo', 'Charlie', 'Alpha']);
245+
});
246+
247+
const searchInput = screen.getByLabelText('tokens.search_label');
248+
const changeSearch = (value: string) => fireEvent.change(searchInput, { target: { value } });
249+
250+
changeSearch('char');
251+
await waitFor(() => {
252+
expect(getTokenNames()).toEqual(['Charlie']);
253+
});
254+
255+
changeSearch('brav');
256+
await waitFor(() => {
257+
expect(getTokenNames()).toEqual(['Bravo']);
258+
});
259+
260+
changeSearch('zzz');
261+
await waitFor(() => {
262+
expect(screen.getByText('tokens.no_tokens_search')).toBeTruthy();
263+
});
264+
265+
changeSearch('');
266+
await waitFor(() => {
267+
expect(getTokenNames()).toEqual(['Bravo', 'Charlie', 'Alpha']);
268+
});
269+
});
164270
});

0 commit comments

Comments
 (0)