mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(settings): confirmations in the shared dialog, token colours on About, scope picker in the new look
This commit is contained in:
@@ -19,6 +19,13 @@ beforeEach(() => {
|
||||
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
|
||||
});
|
||||
|
||||
/** Answers the confirm dialog: its Delete is the last one in the document (portal). */
|
||||
async function confirmDelete(user: ReturnType<typeof userEvent.setup>) {
|
||||
await screen.findByText('Delete category? Places in this category will not be deleted.');
|
||||
const deletes = screen.getAllByRole('button', { name: 'Delete' });
|
||||
await user.click(deletes[deletes.length - 1]);
|
||||
}
|
||||
|
||||
describe('CategoryManager', () => {
|
||||
it('FE-COMP-CAT-001: renders without crashing', () => {
|
||||
render(<CategoryManager />);
|
||||
@@ -116,15 +123,15 @@ describe('CategoryManager', () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Parks');
|
||||
// Delete button is icon-only (Trash2, no title) — find the second action button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
// Nothing goes before the confirm dialog is answered
|
||||
expect(await screen.findByText('Delete category? Places in this category will not be deleted.')).toBeInTheDocument();
|
||||
expect(deleteCalled).toBe(false);
|
||||
await confirmDelete(user);
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
vi.restoreAllMocks();
|
||||
await waitFor(() => expect(screen.queryByText('Parks')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-010: shows subtitle text', async () => {
|
||||
@@ -209,23 +216,23 @@ describe('CategoryManager', () => {
|
||||
expect(screen.getByDisplayValue('Parks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-016: declining the delete confirm keeps the category', async () => {
|
||||
it('FE-COMP-CAT-016: cancelling the delete confirm keeps the category', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Parks');
|
||||
|
||||
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await screen.findByText('Delete category? Places in this category will not be deleted.');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(screen.queryByText('Delete category? Places in this category will not be deleted.')).not.toBeInTheDocument();
|
||||
expect(deleteCalled).toBe(false);
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-017: a failing delete toasts and keeps the row', async () => {
|
||||
@@ -234,16 +241,14 @@ describe('CategoryManager', () => {
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })),
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Parks');
|
||||
|
||||
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
await confirmDelete(user);
|
||||
|
||||
expect(await screen.findByText('category in use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-018: picking an icon and a preset colour updates the live preview', async () => {
|
||||
@@ -293,7 +298,7 @@ describe('CategoryManager', () => {
|
||||
await user.click(screen.getByText('New Category'));
|
||||
expect(screen.getByPlaceholderText('Category name')).toHaveValue('');
|
||||
|
||||
const row = screen.getByText('Hotels').closest('.p-3') as HTMLElement;
|
||||
const row = screen.getByText('Hotels').closest('[data-category-row]') as HTMLElement;
|
||||
await user.click(within(row).getAllByRole('button')[0]);
|
||||
|
||||
// Only the inline edit form remains, pre-filled with the row's name
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { Plus, Pencil, Trash2, Pipette, Tags, Loader2 } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit'
|
||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -27,6 +28,8 @@ export default function CategoryManager() {
|
||||
const [form, setForm] = useState({ name: '', color: DEFAULT_COLOR, icon: 'MapPin' })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
// The category whose delete waits for the answer in the confirm dialog.
|
||||
const [deleteId, setDeleteId] = useState(null)
|
||||
const colorInputRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
@@ -87,7 +90,6 @@ export default function CategoryManager() {
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm(t('categories.confirm.delete'))) return
|
||||
try {
|
||||
await categoriesApi.delete(id)
|
||||
setCategories(prev => prev.filter(c => c.id !== id))
|
||||
@@ -236,7 +238,7 @@ export default function CategoryManager() {
|
||||
{categories.map(cat => {
|
||||
const Icon = getCategoryIcon(cat.icon)
|
||||
return (
|
||||
<div key={cat.id} className={`group flex items-center gap-3 p-3 ${editingId === cat.id ? 'bg-surface-secondary' : ''}`}>
|
||||
<div key={cat.id} data-category-row={cat.id} className={`group flex items-center gap-3 px-3.5 py-3 ${editingId === cat.id ? 'bg-surface-secondary' : ''}`}>
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[10px]"
|
||||
style={{ backgroundColor: `${cat.color}20` }}>
|
||||
<Icon size={17} strokeWidth={1.8} color={cat.color} />
|
||||
@@ -255,7 +257,7 @@ export default function CategoryManager() {
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('common.delete')}>
|
||||
<button type="button" onClick={() => handleDelete(cat.id)} aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}>
|
||||
<button type="button" onClick={() => setDeleteId(cat.id)} aria-label={t('common.delete')} className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -266,6 +268,14 @@ export default function CategoryManager() {
|
||||
</SettingRows>
|
||||
)}
|
||||
{formOpen && categoryDialog}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteId !== null}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={() => { if (deleteId !== null) void handleDelete(deleteId) }}
|
||||
message={t('categories.confirm.delete')}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ function setup(overrides: Partial<ComponentProps<typeof PageSidebar>> = {}) {
|
||||
|
||||
describe('PageSidebar', () => {
|
||||
it('FE-W4PSB-001: renders the label, every tab and the panel children', () => {
|
||||
setup()
|
||||
// Ungrouped tabs: the label heads the list. Grouped ones lead with their group instead.
|
||||
setup({ tabs: TABS.map(({ group: _group, ...tab }) => tab) })
|
||||
|
||||
expect(screen.getAllByText('SETTINGS').length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: /^Appearance$/ })).toBeInTheDocument()
|
||||
@@ -36,6 +37,7 @@ describe('PageSidebar', () => {
|
||||
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
expect(screen.getByText('Account')).toBeInTheDocument()
|
||||
expect(screen.queryByText('SETTINGS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4PSB-003: highlights the active tab', () => {
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronRight, Minus } from 'lucide-react'
|
||||
import { getScopesByGroup } from '../../api/oauthScopes'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SettingRows, SETTINGS_BUTTON } from '../Settings/settingsKit'
|
||||
|
||||
interface Props {
|
||||
selected: string[]
|
||||
onChange: (scopes: string[]) => void
|
||||
}
|
||||
|
||||
/** The square of a checkbox in the accent look: ticked, partly ticked (mixed) or empty. */
|
||||
function CheckBox({ checked, mixed = false }: { checked: boolean; mixed?: boolean }) {
|
||||
const on = checked || mixed
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-[18px] w-[18px] flex-none place-items-center rounded-[5px] border transition-colors ${
|
||||
on ? 'border-transparent bg-accent text-accent-text' : 'border-edge bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
{mixed ? <Minus size={12} strokeWidth={3} /> : checked ? <Check size={12} strokeWidth={3} /> : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scopes an OAuth client may ask for, grouped like the consent page: one row
|
||||
* per group with a fold-out list of its scopes. Every tick is a button with
|
||||
* role="checkbox", so it reads as one and answers to Space and Enter.
|
||||
*/
|
||||
export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({})
|
||||
@@ -17,77 +39,85 @@ export default function ScopeGroupPicker({ selected, onChange }: Props): React.R
|
||||
const allSelected = allScopeKeys.every(s => selected.includes(s))
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-end mb-2">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
|
||||
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 border-edge text-content-secondary">
|
||||
className={`${SETTINGS_BUTTON} px-3 py-1.5`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const groupScopeKeys = groupScopes.map(s => s.scope)
|
||||
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
|
||||
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden border-edge">
|
||||
<div className="flex items-center gap-1 px-3 py-2 bg-surface-secondary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
|
||||
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left text-content-secondary">
|
||||
{open[group]
|
||||
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
: <ChevronRight className="w-3 h-3 flex-shrink-0" />}
|
||||
{group}
|
||||
{someGroupSelected && (
|
||||
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
|
||||
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={e => onChange(
|
||||
e.target.checked
|
||||
? [...new Set([...selected, ...groupScopeKeys])]
|
||||
: selected.filter(s => !groupScopeKeys.includes(s))
|
||||
)}
|
||||
className="rounded"
|
||||
title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
|
||||
/>
|
||||
</div>
|
||||
{open[group] && (
|
||||
<div className="divide-y border-edge">
|
||||
{groupScopes.map(({ scope, label, description }) => (
|
||||
<label
|
||||
key={scope}
|
||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(scope)}
|
||||
onChange={e => onChange(
|
||||
e.target.checked
|
||||
? [...selected, scope]
|
||||
: selected.filter(s => s !== scope)
|
||||
)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<div className="text-xs font-medium text-content">
|
||||
{label}
|
||||
<p className="text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<div className="max-h-96 overflow-y-auto overscroll-contain">
|
||||
<SettingRows>
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const groupScopeKeys = groupScopes.map(s => s.scope)
|
||||
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
|
||||
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
|
||||
const mixed = someGroupSelected && !allGroupSelected
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="flex items-center gap-2 px-3.5 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
|
||||
aria-expanded={!!open[group]}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-content-secondary hover:text-content"
|
||||
>
|
||||
{open[group]
|
||||
? <ChevronDown size={13} className="flex-none" />
|
||||
: <ChevronRight size={13} className="flex-none" />}
|
||||
<span className="truncate font-geist font-bold uppercase tracking-[.08em]" style={fs(10.5)}>{group}</span>
|
||||
{someGroupSelected && (
|
||||
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={mixed ? 'mixed' : allGroupSelected}
|
||||
aria-label={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
|
||||
onClick={() => onChange(
|
||||
allGroupSelected
|
||||
? selected.filter(s => !groupScopeKeys.includes(s))
|
||||
: [...new Set([...selected, ...groupScopeKeys])]
|
||||
)}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-[8px] hover:bg-surface-secondary"
|
||||
>
|
||||
<CheckBox checked={allGroupSelected} mixed={mixed} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{open[group] && (
|
||||
<div className="divide-y divide-edge-faint border-t border-edge-faint bg-surface-secondary">
|
||||
{groupScopes.map(({ scope, label, description }) => {
|
||||
const on = selected.includes(scope)
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={on}
|
||||
onClick={() => onChange(on ? selected.filter(s => s !== scope) : [...selected, scope])}
|
||||
className="flex w-full items-start gap-2.5 px-3.5 py-2.5 text-left transition-colors hover:bg-surface-tertiary"
|
||||
>
|
||||
<span className="mt-px"><CheckBox checked={on} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium text-content" style={fs(12.5, 'body')}>{label}</span>
|
||||
<span className="block leading-snug text-content-faint" style={fs(11.5)}>{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</SettingRows>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -80,69 +80,54 @@ describe('AboutTab', () => {
|
||||
expect(screen.queryByText('v2.9.10')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-012: Ko-fi link hover changes border and box-shadow styles', () => {
|
||||
// The hover look lives in Tailwind classes driven by the tile's `--tile`
|
||||
// colour now, so the tests pin the colour each tile hands its classes.
|
||||
const tile = (link: HTMLAnchorElement) => ({
|
||||
color: link.style.getPropertyValue('--tile'),
|
||||
hovers: link.className.includes('hover:border-[color:var(--tile)]'),
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-012: the Ko-fi tile carries its brand colour for the hover border', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = screen.getByText('Ko-fi').closest('a') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(255, 94, 91)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
expect(link).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe');
|
||||
expect(tile(link)).toEqual({ color: '#ff5e5b', hovers: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-013: Buy Me a Coffee link hover changes border and box-shadow styles', () => {
|
||||
it('FE-COMP-ABOUT-013: the Buy Me a Coffee tile carries its brand colour for the hover border', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = screen.getByText('Buy Me a Coffee').closest('a') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(255, 221, 0)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
expect(link).toHaveAttribute('href', 'https://buymeacoffee.com/mauriceboe');
|
||||
expect(tile(link)).toEqual({ color: '#ffdd00', hovers: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-014: Discord link hover changes border and box-shadow styles', () => {
|
||||
it('FE-COMP-ABOUT-014: the Discord tile carries its brand colour for the hover border', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = screen.getByText('Discord').closest('a') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(88, 101, 242)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
expect(link).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
|
||||
expect(tile(link)).toEqual({ color: '#5865F2', hovers: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-015: Bug report link hover changes border and box-shadow styles', () => {
|
||||
it('FE-COMP-ABOUT-015: the bug report tile uses the danger token', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = document.querySelector('a[href*="issues/new"]') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(239, 68, 68)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(tile(link)).toEqual({ color: 'var(--danger)', hovers: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-016: Feature request link hover changes border and box-shadow styles', () => {
|
||||
it('FE-COMP-ABOUT-016: the feature request tile uses the warning token', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = document.querySelector('a[href*="discussions/new"]') as HTMLAnchorElement;
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(245, 158, 11)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(tile(link)).toEqual({ color: 'var(--warning)', hovers: true });
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-017: Wiki link hover changes border and box-shadow styles', () => {
|
||||
it('FE-COMP-ABOUT-017: the wiki tile uses the info token and no inline hover styles are left', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const link = document.querySelector('a[href*="wiki"]') as HTMLAnchorElement;
|
||||
expect(tile(link)).toEqual({ color: 'var(--info)', hovers: true });
|
||||
fireEvent.mouseEnter(link);
|
||||
expect(link.style.borderColor).toBe('rgb(99, 102, 241)');
|
||||
expect(link.style.boxShadow).not.toBe('');
|
||||
fireEvent.mouseLeave(link);
|
||||
expect(link.style.borderColor).toBe('var(--border-primary)');
|
||||
expect(link.style.boxShadow).toBe('none');
|
||||
expect(link.style.borderColor).toBe('');
|
||||
expect(link.style.boxShadow).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,49 +14,46 @@ const DISCORD_PATH = 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0
|
||||
|
||||
// The services' own colours: each tile takes its brand on hover and in its icon square.
|
||||
const BRAND = {
|
||||
kofi: '#ff5e5b', // theme-lint-disable: Ko-fi brand colour
|
||||
bmac: '#ffdd00', // theme-lint-disable: Buy Me a Coffee brand colour
|
||||
discord: '#5865F2', // theme-lint-disable: Discord brand colour
|
||||
bug: '#ef4444', // theme-lint-disable: the bug tile's signal red, a fixed brand-like mark
|
||||
idea: '#f59e0b', // theme-lint-disable: the idea tile's signal amber, a fixed brand-like mark
|
||||
wiki: '#6366f1', // theme-lint-disable: the wiki tile's signal indigo, a fixed brand-like mark
|
||||
kofi: '#ff5e5b', // theme-lint-disable: brand colour (Ko-fi)
|
||||
bmac: '#ffdd00', // theme-lint-disable: brand colour (Buy Me a Coffee)
|
||||
discord: '#5865F2', // theme-lint-disable: brand colour (Discord)
|
||||
} as const;
|
||||
|
||||
// TREK's own tiles speak in the theme's signal colours instead.
|
||||
const SIGNAL = {
|
||||
bug: 'var(--danger)',
|
||||
idea: 'var(--warning)',
|
||||
wiki: 'var(--info)',
|
||||
} as const;
|
||||
|
||||
/** Tile chrome that reads the tile's colour from `--tile`: the border and a faint ring on hover. */
|
||||
const TILE_HOVER = 'hover:border-[color:var(--tile)] hover:shadow-[0_0_0_1px_color-mix(in_srgb,var(--tile)_14%,transparent)]';
|
||||
/** The icon square in the tile's colour. */
|
||||
const TILE_ICON = 'bg-[color-mix(in_srgb,var(--tile)_12%,transparent)] text-[color:var(--tile)]';
|
||||
|
||||
/**
|
||||
* One outbound link as a tile: a tinted icon square, a title and a hint, the
|
||||
* external-link mark on the right. The border picks up the brand colour while
|
||||
* external-link mark on the right. The border picks up the tile's colour while
|
||||
* the pointer is on it.
|
||||
*/
|
||||
function LinkTile({ href, brand, icon, title, hint }: {
|
||||
function LinkTile({ href, color, icon, title, hint }: {
|
||||
href: string;
|
||||
brand?: string;
|
||||
/** A brand colour or a theme token, handed to the classes as `--tile`. */
|
||||
color?: string;
|
||||
icon?: ReactNode;
|
||||
title: ReactNode;
|
||||
hint: ReactNode;
|
||||
}) {
|
||||
const tint: CSSProperties | undefined = brand
|
||||
? { background: `color-mix(in srgb, ${brand} 12%, transparent)`, color: brand }
|
||||
: undefined;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-w-0 items-center gap-3 overflow-hidden rounded-[14px] border border-edge bg-surface-card px-3.5 py-3 no-underline shadow-sm transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={brand ? (e) => {
|
||||
e.currentTarget.style.borderColor = brand;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 1px ${brand}22`;
|
||||
} : undefined}
|
||||
onMouseLeave={brand ? (e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
} : undefined}
|
||||
className={`flex min-w-0 items-center gap-3 overflow-hidden rounded-[14px] border border-edge bg-surface-card px-3.5 py-3 no-underline shadow-sm transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] ${color ? TILE_HOVER : ''}`}
|
||||
style={color ? ({ '--tile': color } as CSSProperties) : undefined}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] ${brand ? '' : 'bg-surface-tertiary text-content-secondary'}`}
|
||||
style={tint}
|
||||
>
|
||||
<span className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] ${color ? TILE_ICON : 'bg-surface-tertiary text-content-secondary'}`}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
@@ -112,21 +109,21 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<LinkTile
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
brand={BRAND.kofi}
|
||||
color={BRAND.kofi}
|
||||
icon={<Coffee size={19} />}
|
||||
title="Ko-fi"
|
||||
hint={t('admin.github.support')}
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
brand={BRAND.bmac}
|
||||
color={BRAND.bmac}
|
||||
icon={<Heart size={19} />}
|
||||
title="Buy Me a Coffee"
|
||||
hint={t('admin.github.support')}
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
brand={BRAND.discord}
|
||||
color={BRAND.discord}
|
||||
icon={(
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d={DISCORD_PATH} />
|
||||
@@ -137,21 +134,21 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
brand={BRAND.bug}
|
||||
color={SIGNAL.bug}
|
||||
icon={<Bug size={19} />}
|
||||
title={t('settings.about.reportBug')}
|
||||
hint={t('settings.about.reportBugHint')}
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
brand={BRAND.idea}
|
||||
color={SIGNAL.idea}
|
||||
icon={<Lightbulb size={19} />}
|
||||
title={t('settings.about.featureRequest')}
|
||||
hint={t('settings.about.featureRequestHint')}
|
||||
/>
|
||||
<LinkTile
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
brand={BRAND.wiki}
|
||||
color={SIGNAL.wiki}
|
||||
icon={<BookOpen size={19} />}
|
||||
title="Wiki"
|
||||
hint={t('settings.about.wikiHint')}
|
||||
|
||||
@@ -512,23 +512,24 @@ describe('OfflineTab', () => {
|
||||
expect(screen.queryByText('Trips')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-023: Clear cache asks first and a declined confirm keeps the data', async () => {
|
||||
it('FE-COMP-OFFLINETAB-023: Clear cache asks first and a cancelled confirm keeps the data', async () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
cache([{ trip: paris }]);
|
||||
render(<OfflineTab />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeEnabled());
|
||||
await user.click(screen.getByRole('button', { name: 'Clear cache' }));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Clear all offline trip data? You can re-sync anytime while online.');
|
||||
expect(await screen.findByText('Clear all offline trip data? You can re-sync anytime while online.')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(screen.queryByText('Clear all offline trip data? You can re-sync anytime while online.')).not.toBeInTheDocument();
|
||||
expect(h.clearAll).not.toHaveBeenCalled();
|
||||
expect(within(card('Offline cache')).getByText('Paris')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-024: confirming Clear cache wipes the database and reloads', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
cache([{ trip: paris }]);
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -536,6 +537,11 @@ describe('OfflineTab', () => {
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
await user.click(screen.getByRole('button', { name: 'Clear cache' }));
|
||||
await screen.findByText('Clear all offline trip data? You can re-sync anytime while online.');
|
||||
expect(h.clearAll).not.toHaveBeenCalled();
|
||||
// The dialog's own confirm button is the last "Clear cache" in the document.
|
||||
const confirmButtons = screen.getAllByRole('button', { name: 'Clear cache' });
|
||||
await user.click(confirmButtons[confirmButtons.length - 1]!);
|
||||
|
||||
expect(h.clearAll).toHaveBeenCalledTimes(1);
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* All of the logic lives in `useOfflineSettings`, shared with the phone twin
|
||||
* `MSettingsOffline`; this file is the desktop markup over it.
|
||||
*/
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
@@ -18,6 +18,7 @@ import { SettingRow, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SET
|
||||
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from './useOfflineSettings'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import type { ConflictStrategy } from '../../sync/offlinePrefs'
|
||||
import type { QueuedMutation } from '../../db/offlineDb'
|
||||
@@ -42,6 +43,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
} = useOfflineSettings()
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
@@ -207,8 +209,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
icon={Database}
|
||||
action={
|
||||
<button type="button"
|
||||
// ConfirmDialog would be the planner's way; the tab's tests pin window.confirm.
|
||||
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={clearing || !canClear}
|
||||
className={SETTINGS_BUTTON_DANGER}
|
||||
style={fs(12.5, 'body')}
|
||||
@@ -257,6 +258,16 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</SettingRows>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmClear}
|
||||
onClose={() => setConfirmClear(false)}
|
||||
onConfirm={() => { void handleClear() }}
|
||||
title={t('settings.offline.clear')}
|
||||
message={t('settings.offline.clearConfirm')}
|
||||
confirmLabel={t('settings.offline.clear')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,9 +326,8 @@ describe('PluginSettingsTab', () => {
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-018: a dangerous action asks first and a declined confirm skips it', async () => {
|
||||
it('FE-COMP-PLUGINSETTINGS-018: a dangerous action asks first and a cancelled confirm skips it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
let ran = 0;
|
||||
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/wipe', () => {
|
||||
@@ -339,14 +338,18 @@ describe('PluginSettingsTab', () => {
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Wipe cache' }));
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Run this action?');
|
||||
expect(await screen.findByText('Run this action?')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByText('Run this action?')).not.toBeInTheDocument();
|
||||
expect(ran).toBe(0);
|
||||
|
||||
confirmSpy.mockReturnValue(true);
|
||||
await user.click(screen.getByRole('button', { name: 'Wipe cache' }));
|
||||
await screen.findByText('Run this action?');
|
||||
// The dialog's confirm button carries the action's label and sits last in the document.
|
||||
const confirmButtons = screen.getAllByRole('button', { name: 'Wipe cache' });
|
||||
await user.click(confirmButtons[confirmButtons.length - 1]!);
|
||||
await screen.findByText('Wiped');
|
||||
expect(ran).toBe(1);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-019: an unconfigured OAuth integration renders no connect row', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Save, Loader2, Link2, Unlink, CheckCircle, Puzzle, Zap } from 'lucide-react'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { SettingRow, SettingRows, SettingsCard, SettingsHint, SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY } from './settingsKit'
|
||||
@@ -107,6 +108,8 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
const [actions, setActions] = useState<PluginAction[]>([])
|
||||
const [running, setRunning] = useState<string | null>(null)
|
||||
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message?: string }>>({})
|
||||
// A dangerous action waits here for the user's answer in the confirm dialog.
|
||||
const [pendingAction, setPendingAction] = useState<PluginAction | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
@@ -128,8 +131,12 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
|
||||
// An action runs AS the caller, so it sees the values they just saved — run the save
|
||||
// first if the form is dirty would be nicer, but keeping it explicit is less surprising.
|
||||
const runAction = async (a: PluginAction) => {
|
||||
if (a.danger && !window.confirm(t('settings.plugins.actions.confirm'))) return
|
||||
const runAction = (a: PluginAction) => {
|
||||
if (a.danger) { setPendingAction(a); return }
|
||||
void performAction(a)
|
||||
}
|
||||
|
||||
const performAction = async (a: PluginAction) => {
|
||||
setRunning(a.key)
|
||||
try {
|
||||
const res = await pluginsApi.runAction(id, a.key)
|
||||
@@ -259,6 +266,14 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
</div>
|
||||
)}
|
||||
<PluginOAuthSection id={id} state={oauth} setState={setOauth} />
|
||||
<ConfirmDialog
|
||||
isOpen={pendingAction !== null}
|
||||
onClose={() => setPendingAction(null)}
|
||||
onConfirm={() => { if (pendingAction) void performAction(pendingAction) }}
|
||||
message={t('settings.plugins.actions.confirm')}
|
||||
confirmLabel={pendingAction?.label}
|
||||
danger
|
||||
/>
|
||||
</SettingsCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -440,6 +440,9 @@ describe('AdminPage', () => {
|
||||
// MSW returns [admin, alice]; alice's delete button is index 1
|
||||
const deleteButtons = screen.getAllByLabelText(/delete/i);
|
||||
fireEvent.click(deleteButtons[1]);
|
||||
// The desktop tab asks in its own dialog; its confirm button renders last.
|
||||
const confirmButtons = await screen.findAllByRole('button', { name: /^delete$/i });
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('alice')).not.toBeInTheDocument();
|
||||
@@ -1298,6 +1301,8 @@ describe('AdminPage', () => {
|
||||
// Click delete for alice (second user — non-self)
|
||||
const deleteButtons = screen.getAllByLabelText('Delete user');
|
||||
fireEvent.click(deleteButtons[deleteButtons.length - 1]); // last button = alice
|
||||
const confirmButtons = await screen.findAllByRole('button', { name: /^delete$/i });
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deletedId).toBeDefined();
|
||||
|
||||
@@ -52,6 +52,13 @@ function EditFormHarness() {
|
||||
return <AdminUserModals admin={admin} t={t} />;
|
||||
}
|
||||
|
||||
/** Answers the confirm dialog: its button repeats the label and sits last in the document. */
|
||||
function confirmPasskeyReset() {
|
||||
expect(screen.getByText('Remove all passkeys for alice?')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: /reset passkeys/i });
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
}
|
||||
|
||||
const editing = {
|
||||
editingUser: alice,
|
||||
editForm: { username: 'alice', email: 'alice@example.com', role: 'user', password: '' },
|
||||
@@ -183,18 +190,31 @@ describe('AdminUserModals', () => {
|
||||
expect(admin.handleSaveUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMMOD-012: resetting passkeys is skipped when the confirm is declined', () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
it('FE-ADMMOD-012: resetting passkeys is skipped when the confirm is cancelled', async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.delete('/api/admin/users/:id/passkeys', () => {
|
||||
calls += 1;
|
||||
return HttpResponse.json({ deleted: 1 });
|
||||
})
|
||||
);
|
||||
const admin = renderModals(editing);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /reset passkeys/i }));
|
||||
expect(screen.getByText('Remove all passkeys for alice?')).toBeInTheDocument();
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith('Remove all passkeys for alice?');
|
||||
// The edit dialog has its own Cancel; the question's Cancel is the last one.
|
||||
const cancels = screen.getAllByRole('button', { name: /^cancel$/i });
|
||||
fireEvent.click(cancels[cancels.length - 1]);
|
||||
|
||||
expect(screen.queryByText('Remove all passkeys for alice?')).not.toBeInTheDocument();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(calls).toBe(0);
|
||||
expect(admin.toast.success).not.toHaveBeenCalled();
|
||||
expect(admin.setEditingUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-ADMMOD-013: a confirmed passkey reset reports how many were removed', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
let deletedFor: string | undefined;
|
||||
server.use(
|
||||
http.delete('/api/admin/users/:id/passkeys', ({ params }) => {
|
||||
@@ -205,17 +225,18 @@ describe('AdminUserModals', () => {
|
||||
const admin = renderModals(editing);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /reset passkeys/i }));
|
||||
confirmPasskeyReset();
|
||||
|
||||
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('Removed 3 passkey(s)'));
|
||||
expect(deletedFor).toBe('2');
|
||||
});
|
||||
|
||||
it('FE-ADMMOD-014: a failing passkey reset toasts the generic error', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
server.use(http.delete('/api/admin/users/:id/passkeys', () => HttpResponse.json({}, { status: 500 })));
|
||||
const admin = renderModals(editing);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /reset passkeys/i }));
|
||||
confirmPasskeyReset();
|
||||
|
||||
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error'));
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import React, { useId } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import CustomSelect from '../../components/shared/CustomSelect';
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog';
|
||||
import { Tooltip } from '../../components/shared/Tooltip';
|
||||
import {
|
||||
DialogButton,
|
||||
@@ -115,6 +116,8 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
} = admin;
|
||||
const [showCreatePw, setShowCreatePw] = React.useState(false);
|
||||
const [showEditPw, setShowEditPw] = React.useState(false);
|
||||
// The user whose passkeys wait for the admin's answer in the confirm dialog.
|
||||
const [passkeyResetUser, setPasskeyResetUser] = React.useState<typeof editingUser>(null);
|
||||
const uid = useId();
|
||||
const createId = `${uid}-create`;
|
||||
const editId = `${uid}-edit`;
|
||||
@@ -280,15 +283,8 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!editingUser) return;
|
||||
if (!confirm(t('admin.passkey.resetConfirm', { name: editingUser.username }))) return;
|
||||
try {
|
||||
const r = await adminApi.resetUserPasskeys(editingUser.id);
|
||||
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
onClick={() => {
|
||||
if (editingUser) setPasskeyResetUser(editingUser);
|
||||
}}
|
||||
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
|
||||
style={fs(12.5, 'body')}
|
||||
@@ -445,6 +441,24 @@ docker run -d --name trek \\
|
||||
</p>
|
||||
</div>
|
||||
</DialogShell>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={passkeyResetUser !== null}
|
||||
onClose={() => setPasskeyResetUser(null)}
|
||||
onConfirm={async () => {
|
||||
if (!passkeyResetUser) return;
|
||||
try {
|
||||
const r = await adminApi.resetUserPasskeys(passkeyResetUser.id);
|
||||
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }));
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}}
|
||||
title={t('admin.passkey.reset')}
|
||||
message={passkeyResetUser ? t('admin.passkey.resetConfirm', { name: passkeyResetUser.username }) : ''}
|
||||
confirmLabel={t('admin.passkey.reset')}
|
||||
danger
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,13 +125,27 @@ describe('AdminUsersTab', () => {
|
||||
expect(admin.handleEditUser).toHaveBeenCalledWith(alice);
|
||||
});
|
||||
|
||||
it('FE-ADMUSR-009: delete button forwards the row user to handleDeleteUser', () => {
|
||||
it('FE-ADMUSR-009: delete asks first and forwards the row user to handleDeleteUser on confirm', () => {
|
||||
const admin = renderTab({ users: [me, alice], currentUser: me });
|
||||
|
||||
const deleteButtons = screen.getAllByLabelText('Delete user');
|
||||
fireEvent.click(deleteButtons[1]);
|
||||
|
||||
expect(admin.handleDeleteUser).toHaveBeenCalledWith(alice);
|
||||
expect(admin.handleDeleteUser).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(`Delete user "${alice.username}"? All trips will be permanently deleted.`)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(admin.handleDeleteUser).toHaveBeenCalledWith(alice, { confirmed: true });
|
||||
});
|
||||
|
||||
it('FE-ADMUSR-009b: cancelling the delete question deletes nothing', () => {
|
||||
const admin = renderTab({ users: [me, alice], currentUser: me });
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText('Delete user')[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(screen.queryByText(`Delete user "${alice.username}"? All trips will be permanently deleted.`)).not.toBeInTheDocument();
|
||||
expect(admin.handleDeleteUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-ADMUSR-010: the delete button of the current user is disabled', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useId, type ReactNode } from 'react'
|
||||
import React, { useId, useState, type ReactNode } from 'react'
|
||||
import CustomSelect from '../../components/shared/CustomSelect'
|
||||
import { Shield, Trash2, Edit2, UserPlus, Link2, Copy, Plus, Users } from 'lucide-react'
|
||||
import PermissionsPanel from '../../components/Admin/PermissionsPanel'
|
||||
import { Tooltip } from '../../components/shared/Tooltip'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../../components/shared/DialogShell'
|
||||
import { EditorField, Segmented } from '../../components/shared/dialogParts'
|
||||
import { SettingsCard, SettingRows, SettingsHint, StatusPill, SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../../components/Settings/settingsKit'
|
||||
@@ -42,6 +43,8 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
handleEditUser, handleDeleteUser,
|
||||
} = admin
|
||||
const inviteLabelId = useId()
|
||||
// The row whose delete waits for the admin's answer in the confirm dialog.
|
||||
const [userToDelete, setUserToDelete] = useState<(typeof users)[number] | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -117,7 +120,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
<RowIconButton label={t('admin.editUser')} onClick={() => handleEditUser(u)}>
|
||||
<Edit2 size={14} strokeWidth={2} />
|
||||
</RowIconButton>
|
||||
<RowIconButton label={t('admin.deleteUserTitle')} onClick={() => handleDeleteUser(u)} disabled={isMe} danger>
|
||||
<RowIconButton label={t('admin.deleteUserTitle')} onClick={() => setUserToDelete(u)} disabled={isMe} danger>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</RowIconButton>
|
||||
</div>
|
||||
@@ -253,6 +256,16 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
</EditorField>
|
||||
)}
|
||||
</DialogShell>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={userToDelete !== null}
|
||||
onClose={() => setUserToDelete(null)}
|
||||
onConfirm={() => { if (userToDelete) void handleDeleteUser(userToDelete, { confirmed: true }) }}
|
||||
title={t('admin.deleteUserTitle')}
|
||||
message={userToDelete ? t('admin.deleteUser', { name: userToDelete.username }) : ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -800,6 +800,19 @@ describe('useAdmin', () => {
|
||||
expect(toastCalls).toContainEqual({ type: 'success', message: 'User deleted' });
|
||||
});
|
||||
|
||||
it('FE-ADMHOOK-042b: a delete the caller already confirmed skips the browser confirm', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
server.use(http.delete('/api/admin/users/:id', () => HttpResponse.json({ success: true })));
|
||||
const { result } = await mountAdmin();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteUser(result.current.users[1], { confirmed: true });
|
||||
});
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(result.current.users.map(u => u.username)).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('FE-ADMHOOK-043: a failing delete surfaces the server error', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
server.use(http.delete('/api/admin/users/:id', () => HttpResponse.json({ error: 'busy' }, { status: 500 })));
|
||||
|
||||
@@ -492,12 +492,14 @@ export function useAdmin() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (user) => {
|
||||
// `confirmed` means the caller already asked in its own ConfirmDialog (the
|
||||
// desktop users tab); without it the browser's confirm asks, as on mobile.
|
||||
const handleDeleteUser = async (user, opts: { confirmed?: boolean } = {}) => {
|
||||
if (user.id === currentUser?.id) {
|
||||
toast.error(t('admin.toast.cannotDeleteSelf'))
|
||||
return
|
||||
}
|
||||
if (!confirm(t('admin.deleteUser', { name: user.username }))) return
|
||||
if (!opts.confirmed && !confirm(t('admin.deleteUser', { name: user.username }))) return
|
||||
try {
|
||||
await adminApi.deleteUser(user.id)
|
||||
setUsers(prev => prev.filter(u => u.id !== user.id))
|
||||
|
||||
Reference in New Issue
Block a user