feat(settings,admin): every tab and popup in the planner's design

This commit is contained in:
Maurice
2026-10-01 16:00:37 +02:00
parent 4bc7c5cf68
commit 788a58679e
75 changed files with 8134 additions and 8023 deletions
@@ -1,7 +1,9 @@
import { Check, Lock, Minus } from 'lucide-react'
import { Check, Lock, Minus, UsersRound } from 'lucide-react'
import type { NotificationDefault } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../../components/shared/Tooltip'
import { SettingsCard } from '../../components/Settings/settingsKit'
import { fs } from '../../components/shared/DialogShell'
import { EVENT_LABEL_KEYS, channelLabel } from '../../components/Settings/notificationLabels'
import { useNotificationDefaults } from './useNotificationDefaults'
@@ -32,60 +34,56 @@ export default function AdminNotificationDefaultsPanel() {
const columns = `minmax(0, 1fr) ${channels.map(() => '76px').join(' ')}`
return (
<section className="rounded-xl border border-edge bg-surface-card" aria-labelledby="notif-defaults-title">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-edge-faint px-6 py-4">
<div className="min-w-0 flex-1 basis-72">
<h2 id="notif-defaults-title" className="font-semibold text-content">{t('admin.notificationDefaults.title')}</h2>
<p className="mt-1 text-xs text-content-faint">{t('admin.notificationDefaults.hint')}</p>
</div>
{/* The legend is the key to the cells below, so it reads in the same three chips. */}
<div className="flex flex-wrap items-center gap-1.5" aria-hidden="true">
{(['on', 'off', 'blocked'] as const).map(state => {
const Icon = ICON[state]
return (
<span key={state} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${TONE[state]}`}>
<Icon size={11} strokeWidth={2.6} />
{t(LABEL_KEYS[state])}
</span>
)
})}
</div>
<SettingsCard id="notif-defaults" icon={UsersRound} title={t('admin.notificationDefaults.title')} hint={t('admin.notificationDefaults.hint')}>
{/* The legend is the key to the cells below, so it reads in the same three chips. */}
<div className="flex flex-wrap items-center gap-1.5" aria-hidden="true">
{(['on', 'off', 'blocked'] as const).map(state => {
const Icon = ICON[state]
return (
<span key={state} className={`inline-flex items-center gap-1 rounded-full border px-2 py-[2px] font-semibold ${TONE[state]}`} style={fs(11)}>
<Icon size={11} strokeWidth={2.6} />
{t(LABEL_KEYS[state])}
</span>
)
})}
</div>
<div className="overflow-x-auto px-6 pb-5 pt-3">
<div className={`overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card transition-opacity ${saving ? 'opacity-60' : ''}`}>
<div className="min-w-max">
<div className="grid items-end gap-1 border-b border-edge pb-1.5" style={{ gridTemplateColumns: columns }}>
<div className="grid items-end gap-1 border-b border-edge-faint px-3.5 pb-2 pt-2.5" style={{ gridTemplateColumns: columns }}>
<span />
{channels.map(ch => (
<span key={ch.id} className="text-center text-caption font-semibold uppercase tracking-[0.04em] text-content-faint">{channelLabel(ch, t)}</span>
<span key={ch.id} className="truncate text-center font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{channelLabel(ch, t)}</span>
))}
</div>
{matrix.event_types.map(eventType => {
const implemented = matrix.implemented_combos[eventType] ?? []
return (
<div key={eventType} className="grid items-center gap-1 border-b border-edge-faint py-2 last:border-b-0" style={{ gridTemplateColumns: columns }}>
<span className="min-w-0 truncate text-body text-content">{t(EVENT_LABEL_KEYS[eventType]) || eventType}</span>
{channels.map(ch => {
if (!implemented.includes(ch.id)) return <span key={ch.id} className="text-center text-content-faint">—</span>
const state = matrix.defaults[eventType]?.[ch.id] ?? 'on'
const Icon = ICON[state]
const label = `${t(EVENT_LABEL_KEYS[eventType]) || eventType}, ${channelLabel(ch, t)}: ${t(LABEL_KEYS[state])}`
return (
<div key={ch.id} className="flex justify-center">
<Tooltip label={t('admin.notificationDefaults.cycle', { next: t(LABEL_KEYS[NEXT[state]]) })}>
<button type="button" aria-label={label} data-state={state} disabled={saving}
onClick={() => void cycle(eventType, ch.id, NEXT[state])}
className={`inline-flex h-7 w-12 items-center justify-center rounded-full border transition-colors disabled:opacity-60 ${TONE[state]}`}>
<Icon size={13} strokeWidth={2.6} />
</button>
</Tooltip>
</div>
)
})}
</div>
)
})}
<div className="divide-y divide-edge-faint">
{matrix.event_types.map(eventType => {
const implemented = matrix.implemented_combos[eventType] ?? []
return (
<div key={eventType} className="grid items-center gap-1 px-3.5 py-2" style={{ gridTemplateColumns: columns }}>
<span className="min-w-0 truncate font-medium text-content" style={fs(13, 'body')}>{t(EVENT_LABEL_KEYS[eventType]) || eventType}</span>
{channels.map(ch => {
if (!implemented.includes(ch.id)) return <span key={ch.id} className="text-center text-content-faint" style={fs(13, 'body')}>—</span>
const state = matrix.defaults[eventType]?.[ch.id] ?? 'on'
const Icon = ICON[state]
const label = `${t(EVENT_LABEL_KEYS[eventType]) || eventType}, ${channelLabel(ch, t)}: ${t(LABEL_KEYS[state])}`
return (
<div key={ch.id} className="flex justify-center">
<Tooltip label={t('admin.notificationDefaults.cycle', { next: t(LABEL_KEYS[NEXT[state]]) })}>
<button type="button" aria-label={label} data-state={state} disabled={saving}
onClick={() => void cycle(eventType, ch.id, NEXT[state])}
className={`inline-flex h-7 w-12 items-center justify-center rounded-full border transition-colors hover:opacity-85 disabled:cursor-default ${TONE[state]}`}>
<Icon size={13} strokeWidth={2.6} />
</button>
</Tooltip>
</div>
)
})}
</div>
)
})}
</div>
</div>
</div>
</section>
</SettingsCard>
)
}
@@ -162,7 +162,7 @@ describe('AdminNotificationsPanel', () => {
fireEvent.click(emailToggle);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
expect(emailToggle.className).toContain('bg-edge');
expect(emailToggle).toHaveAttribute('aria-pressed', 'false');
});
it('FE-ADMMTX-011: a channel that no event implements gets no column', async () => {
@@ -204,7 +204,7 @@ describe('AdminNotificationsPanel', () => {
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error'));
expect(bodies[1]).toEqual({ version_available: { inapp: false, email: true } });
expect(emailToggle.className).toContain('bg-edge');
expect(inappToggle.className).toContain('bg-edge');
expect(emailToggle).toHaveAttribute('aria-pressed', 'false');
expect(inappToggle).toHaveAttribute('aria-pressed', 'false');
});
});
@@ -1,37 +1,65 @@
import React, { useEffect, useRef, useState } from 'react'
import { BellRing, Loader2 } from 'lucide-react'
import { adminApi } from '../../api/client'
import { useToast } from '../../components/shared/Toast'
import ToggleSwitch from '../../components/Settings/ToggleSwitch'
import { SettingsCard, SettingsHint } from '../../components/Settings/settingsKit'
import { fs } from '../../components/shared/DialogShell'
import { ADMIN_EVENT_LABEL_KEYS, ADMIN_CHANNEL_LABEL_KEYS } from './AdminPage.constants'
type Preferences = Record<string, Record<string, boolean>>
interface AdminPreferenceMatrix {
event_types: string[]
channels?: { id: string; active: boolean }[]
implemented_combos: Record<string, string[] | undefined>
preferences: Preferences
}
const EYEBROW = 'text-center font-geist font-bold uppercase tracking-[.08em] text-content-faint'
// Per-event × per-channel admin notification preference matrix.
// Loads its own data and auto-saves each toggle. Markup identical to AdminPage.
// Loads its own data and auto-saves each toggle.
export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
const [matrix, setMatrix] = useState<any>(null)
const [matrix, setMatrix] = useState<AdminPreferenceMatrix | null>(null)
const [saving, setSaving] = useState(false)
// Toggles fire faster than React re-renders, so the live preferences are mirrored in a
// ref. Reading state out of the render closure would let a second toggle undo the first.
const prefsRef = useRef<any>(null)
const prefsRef = useRef<Preferences | null>(null)
const writePrefs = (prefs: any) => {
const writePrefs = (prefs: Preferences) => {
prefsRef.current = prefs
setMatrix((m: any) => m ? { ...m, preferences: prefs } : m)
setMatrix(m => m ? { ...m, preferences: prefs } : m)
}
useEffect(() => {
adminApi.getNotificationPreferences().then((data: any) => {
adminApi.getNotificationPreferences().then((data: AdminPreferenceMatrix) => {
prefsRef.current = data.preferences
setMatrix(data)
}).catch(() => {})
}, [])
if (!matrix) return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
const card = (children: React.ReactNode) => (
<SettingsCard icon={BellRing} title={t('admin.tabs.notifications')} hint={t('admin.notifications.adminNotificationsHint')}>
{children}
</SettingsCard>
)
if (!matrix) {
return card(
<div className="flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
<Loader2 size={14} className="animate-spin" />
<span>Loading…</span>
</div>,
)
}
// Admin-scoped events only ever go out over the built-in channels (plugin channels
// are user-scoped), so this list stays explicit rather than server-driven.
const isActive = (id: string) => matrix.channels?.some((c: { id: string; active: boolean }) => c.id === id && c.active) ?? false
const isActive = (id: string) => matrix.channels?.some(c => c.id === id && c.active) ?? false
const visibleChannels = (['inapp', 'email', 'webhook', 'ntfy'] as const).filter(ch => {
if (!isActive(ch)) return false
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
})
const toggle = async (eventType: string, channel: string) => {
@@ -53,53 +81,40 @@ export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) =
}
if (matrix.event_types.length === 0) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<p className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.notificationPreferences.noChannels')}</p>
</div>
)
return card(<SettingsHint>{t('settings.notificationPreferences.noChannels')}</SettingsHint>)
}
return (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.tabs.notifications')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p>
const columns = `minmax(0, 1fr) ${visibleChannels.map(() => '76px').join(' ')}`
const channelName = (ch: string) => t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch
const eventName = (evt: string) => t(ADMIN_EVENT_LABEL_KEYS[evt]) || evt
return card(
<div className={`overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card transition-opacity ${saving ? 'opacity-60' : ''}`}>
<div className="min-w-max">
{/* The channel names over their columns */}
<div className="grid items-end gap-1 border-b border-edge-faint px-3.5 pb-2 pt-2.5" style={{ gridTemplateColumns: columns }}>
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
{saving ? 'Saving…' : null}
</span>
{visibleChannels.map(ch => (
<span key={ch} className={EYEBROW} style={fs(9.5)}>{channelName(ch)}</span>
))}
</div>
<div className="p-6">
{saving && <p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>Saving…</p>}
{/* Header row */}
<div className="border-b border-edge" style={{ display: 'grid', gridTemplateColumns: `minmax(0, 1fr) ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4 }}>
<span />
{visibleChannels.map(ch => (
<span key={ch} className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
{t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
</span>
))}
</div>
{/* Event rows */}
{matrix.event_types.map((eventType: string) => {
{/* One row per event, a switch per channel that implements it */}
<div className="divide-y divide-edge-faint">
{matrix.event_types.map(eventType => {
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
return (
<div key={eventType} className="border-b border-edge" style={{ display: 'grid', gridTemplateColumns: `minmax(0, 1fr) ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0' }}>
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
{t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
</span>
<div key={eventType} className="grid items-center gap-1 px-3.5 py-2.5" style={{ gridTemplateColumns: columns }}>
<span className="min-w-0 truncate font-medium text-content" style={fs(13, 'body')}>{eventName(eventType)}</span>
{visibleChannels.map(ch => {
if (!implementedForEvent.includes(ch)) {
return <span key={ch} className="text-content-faint" style={{ textAlign: 'center', fontSize: 'calc(14px * var(--fs-scale-body, 1))' }}>—</span>
return <span key={ch} className="text-center text-content-faint" style={fs(13, 'body')}>—</span>
}
const isOn = matrix.preferences[eventType]?.[ch] ?? true
return (
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
<button type="button"
onClick={() => toggle(eventType, ch)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors flex-shrink-0 ${isOn ? 'bg-content' : 'bg-edge'}`}
>
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-white transition-transform duration-200"
style={{ transform: isOn ? 'translateX(16px)' : 'translateX(0)' }} />
</button>
<div key={ch} className="flex justify-center">
<ToggleSwitch on={isOn} label={`${eventName(eventType)}, ${channelName(ch)}`} onToggle={() => { void toggle(eventType, ch) }} />
</div>
)
})}
@@ -108,6 +123,6 @@ export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) =
})}
</div>
</div>
</div>
</div>,
)
}
@@ -37,9 +37,9 @@ function replaySmtp(spy: Spy, callIndex: number, base: SmtpValues = {}): SmtpVal
return updater(base);
}
/** The card element whose <h2> matches the given heading text. */
/** The card (its section) whose <h2> matches the given heading text. */
function card(heading: string | RegExp): HTMLElement {
return screen.getByRole('heading', { name: heading }).closest<HTMLElement>('.rounded-xl')!;
return screen.getByRole('heading', { name: heading }).closest<HTMLElement>('section')!;
}
const EMAIL_ON: SmtpValues = { notification_channels: 'email', smtp_host: 'mail.example.com' };
@@ -98,7 +98,7 @@ describe('AdminNotificationsTab', () => {
it('FE-ADMNOT-004: greys out the SMTP body while the email channel is off', () => {
renderTab({ smtpValues: { notification_channels: 'none' } });
const body = screen.getByPlaceholderText('mail.example.com').closest<HTMLElement>('.p-6')!;
const body = screen.getByTestId('smtp-fields');
expect(body.className).toContain('pointer-events-none');
});
@@ -162,7 +162,7 @@ describe('AdminNotificationsTab', () => {
it('FE-ADMNOT-009: falls back to the legacy singular notification_channel key', () => {
renderTab({ smtpValues: { notification_channel: 'email', smtp_host: 'mail.example.com' } });
const body = screen.getByPlaceholderText('mail.example.com').closest<HTMLElement>('.p-6')!;
const body = screen.getByTestId('smtp-fields');
expect(body.className).not.toContain('pointer-events-none');
});
@@ -179,8 +179,8 @@ describe('AdminNotificationsTab', () => {
it('FE-ADMNOT-011: the TLS toggle flips smtp_skip_tls_verify', () => {
const admin = renderTab({ smtpValues: { ...EMAIL_ON, smtp_skip_tls_verify: 'false' } });
const tlsRow = screen.getByText('Skip TLS certificate check').closest<HTMLElement>('div[style]')!;
fireEvent.click(within(tlsRow).getByRole('button'));
const tlsToggle = screen.getByRole('button', { name: 'Skip TLS certificate check' });
fireEvent.click(tlsToggle);
expect(replaySmtp(admin.setSmtpValues, 0).smtp_skip_tls_verify).toBe('true');
});
@@ -188,8 +188,8 @@ describe('AdminNotificationsTab', () => {
it('FE-ADMNOT-012: the TLS toggle flips back when already enabled', () => {
const admin = renderTab({ smtpValues: { ...EMAIL_ON, smtp_skip_tls_verify: 'true' } });
const tlsRow = screen.getByText('Skip TLS certificate check').closest<HTMLElement>('div[style]')!;
fireEvent.click(within(tlsRow).getByRole('button'));
const tlsToggle = screen.getByRole('button', { name: 'Skip TLS certificate check' });
fireEvent.click(tlsToggle);
expect(replaySmtp(admin.setSmtpValues, 0).smtp_skip_tls_verify).toBe('false');
});
+282 -279
View File
@@ -1,38 +1,62 @@
import React from 'react'
import React, { useId } from 'react'
import { authApi, notificationsApi } from '../../api/client'
import { Save } from 'lucide-react'
import { Bell, BellRing, CalendarClock, Mail, Save, Send, Smartphone, Webhook } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
import AdminNotificationsPanel from './AdminNotificationsPanel'
import AdminNotificationDefaultsPanel from './AdminNotificationDefaultsPanel'
import { SWITCH_ONLY_CHANNELS, useNotificationChannels } from '../../components/Admin/useNotificationChannels'
import ToggleSwitch from '../../components/Settings/ToggleSwitch'
import {
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard,
} from '../../components/Settings/settingsKit'
import { EditorField, GRID_2, INPUT } from '../../components/shared/dialogParts'
import { fs } from '../../components/shared/DialogShell'
interface AdminNotificationsTabProps {
admin: ReturnType<typeof useAdmin>
t: TranslationFn
}
/** The on/off switch at the right of a channel card. */
function ChannelSwitch({ on, label, onToggle }: { on: boolean; label: string; onToggle: () => void }): React.ReactElement {
/** The glyph on each switch-only channel's card; anything else is the push channel. */
const CHANNEL_ICONS: Record<string, LucideIcon> = { webhook: Webhook, ntfy: BellRing }
/** A stored secret comes back from the server as this mask. */
const MASK = '••••••••'
/** The save and test buttons under a card's fields, on a hairline of their own. */
function CardActions({ children }: { children: React.ReactNode }): React.ReactElement {
return <div className="flex flex-wrap items-center gap-2 border-t border-edge-faint pt-4" style={fs(13, 'body')}>{children}</div>
}
/**
* In-app delivery cannot be switched off, so its card shows the switch in the
* on position, greyed and out of reach: the look of a ToggleSwitch, not a button.
*/
function LockedSwitch(): React.ReactElement {
return (
<button type="button"
onClick={onToggle}
aria-pressed={on}
aria-label={label}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${on ? 'bg-content' : 'bg-edge'}`}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-surface-card transition-transform duration-200"
style={{ transform: on ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
<span aria-hidden="true" className="relative inline-block h-6 w-11 flex-none cursor-not-allowed rounded-full bg-accent opacity-50">
<span className="absolute left-[22px] top-0.5 h-5 w-5 rounded-full bg-accent-text shadow-sm" />
</span>
)
}
const SMTP_FIELDS: { key: string; label: string; placeholder: string; type?: string }[] = [
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: MASK, type: 'password' },
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
]
// "Notifications" admin tab: email/webhook/ntfy/web push channel toggles, SMTP
// credentials, trip reminders, admin webhook + ntfy targets, and the per-event
// preference matrix. The channel switches come from useNotificationChannels,
// which the phone section shares.
export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTabProps): React.ReactElement {
const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled, managed } = admin
const ids = useId()
const channels = useNotificationChannels(admin, t)
const emailActive = channels.isActive('email')
@@ -53,6 +77,22 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
} catch { toast.error(t('common.error')) }
}
const smtpField = (field: typeof SMTP_FIELDS[number]) => (
<EditorField key={field.key} label={field.label} htmlFor={`${ids}-${field.key}`}>
{/* A stored password comes back masked. Showing the mask as the VALUE meant
typing a new one appended it to eight bullet characters and saved that,
so the same treatment as the webhook URL below: mask as placeholder. */}
<input
id={`${ids}-${field.key}`}
type={field.type || 'text'}
value={smtpValues[field.key] === MASK ? '' : smtpValues[field.key] || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className={INPUT}
/>
</EditorField>
)
return (<>
{/* Two columns from xl up, the same shape as the Settings tab. Most of these
cards are a title with a switch on the far right, so on a wide screen the
@@ -64,241 +104,207 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
sends or receives themselves on the right. The channel switches stay
together because they all write the same notification_channels list.
On a managed install the two admin-target cards are gone and the right column
is the matrix alone — still a column, so the grid keeps working. */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2 xl:items-start">
<div className="space-y-6">
{/* The relay is the operator's: their host, their credential, their sending
reputation. An instance that could point it elsewhere would send under a
domain it does not own. */}
{!managed && (<>
{/* Email Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.emailPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
</div>
<ChannelSwitch on={emailActive} label={t('admin.notifications.emailPanel.title')} onToggle={() => channels.toggle('email')} />
</div>
<div className={`p-6 space-y-3 ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
{smtpLoaded && [
{ key: 'smtp_host', label: 'SMTP Host', placeholder: 'mail.example.com' },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587' },
{ key: 'smtp_user', label: 'SMTP User', placeholder: 'trek@example.com' },
{ key: 'smtp_pass', label: 'SMTP Password', placeholder: '••••••••', type: 'password' },
{ key: 'smtp_from', label: 'From Address', placeholder: 'trek@example.com' },
].map(field => (
<div key={field.key}>
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
{/* A stored password comes back masked. Showing the mask as the VALUE meant
typing a new one appended it to eight bullet characters and saved that,
so the same treatment as the webhook URL below: mask as placeholder. */}
<input
type={field.type || 'text'}
value={smtpValues[field.key] === '••••••••' ? '' : smtpValues[field.key] || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
))}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0' }}>
<div>
<span className="text-xs font-medium text-slate-500">Skip TLS certificate check</span>
<p className="text-[10px] text-slate-400 mt-0.5">Enable for self-signed certificates on local mail servers</p>
</div>
<button type="button" onClick={() => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${smtpValues.smtp_skip_tls_verify === 'true' ? 'bg-content' : 'bg-edge'}`}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: smtpValues.smtp_skip_tls_verify === 'true' ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
<button type="button" onClick={saveNotifications}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
<Save className="w-4 h-4" />{t('common.save')}
</button>
<button type="button"
onClick={async () => {
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
const payload: Record<string, string> = {}
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
await authApi.updateAppSettings(payload).catch(() => {})
try {
const result = await notificationsApi.testSmtp()
if (result.success) toast.success(t('admin.smtp.testSuccess'))
else toast.error(result.error || t('admin.smtp.testFailed'))
} catch { toast.error(t('admin.smtp.testFailed')) }
}}
disabled={!smtpConfigured}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
is the matrix alone — still a column, so the grid keeps working. Each card
brings its own bottom margin, so the columns only need the gap between them. */}
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">
{/* The relay is the operator's: their host, their credential, their sending
reputation. An instance that could point it elsewhere would send under a
domain it does not own. */}
{!managed && (
<SettingsCard
icon={Mail}
title={t('admin.notifications.emailPanel.title')}
hint={t('admin.smtp.hint')}
action={<ToggleSwitch on={emailActive} label={t('admin.notifications.emailPanel.title')} onToggle={() => channels.toggle('email')} />}
>
{t('admin.smtp.testButton')}
</button>
</div>
</div>
</>)}
{/* Webhook, Ntfy and Web Push: a title, a hint and the switch each */}
{SWITCH_ONLY_CHANNELS.map(ch => (
<div key={ch.id} className="bg-surface-card rounded-xl border border-edge overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-content">{t(ch.titleKey)}</h2>
<p className="text-caption text-content-faint mt-1">{t(ch.hintKey)}</p>
{/* Greyed and out of reach while the channel is off, never hidden. */}
<div data-testid="smtp-fields" className={`flex flex-col gap-4 transition-opacity ${!emailActive ? 'opacity-50 pointer-events-none' : ''}`}>
{smtpLoaded && (<>
<div className={GRID_2}>
{smtpField(SMTP_FIELDS[0])}
{smtpField(SMTP_FIELDS[1])}
</div>
<div className={GRID_2}>
{smtpField(SMTP_FIELDS[2])}
{smtpField(SMTP_FIELDS[3])}
</div>
{smtpField(SMTP_FIELDS[4])}
</>)}
<SettingRows>
<SettingRow
label="Skip TLS certificate check"
hint="Enable for self-signed certificates on local mail servers"
control={
<ToggleSwitch
on={smtpValues.smtp_skip_tls_verify === 'true'}
label="Skip TLS certificate check"
onToggle={() => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
/>
}
/>
</SettingRows>
</div>
<ChannelSwitch on={channels.isActive(ch.id)} label={t(ch.titleKey)} onToggle={() => channels.toggle(ch.id)} />
</div>
</div>
))}
<CardActions>
<button type="button" onClick={saveNotifications} className={SETTINGS_BUTTON_PRIMARY}>
<Save size={14} strokeWidth={2.2} />{t('common.save')}
</button>
<button type="button"
onClick={async () => {
const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
const payload: Record<string, string> = {}
for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
await authApi.updateAppSettings(payload).catch(() => {})
try {
const result = await notificationsApi.testSmtp()
if (result.success) toast.success(t('admin.smtp.testSuccess'))
else toast.error(result.error || t('admin.smtp.testFailed'))
} catch { toast.error(t('admin.smtp.testFailed')) }
}}
disabled={!smtpConfigured}
className={SETTINGS_BUTTON}
>
<Send size={14} strokeWidth={2.2} />{t('admin.smtp.testButton')}
</button>
</CardActions>
</SettingsCard>
)}
{/* In-App Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.inappPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.inappPanel.hint')}</p>
</div>
<div className="relative inline-flex h-6 w-11 items-center rounded-full flex-shrink-0 bg-content"
style={{ opacity: 0.5, cursor: 'not-allowed' }}>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: 'translateX(20px)' }} />
</div>
</div>
{/* Webhook, Ntfy and Web Push: a title, a hint and the switch each. Nothing
to set below them, so the card is its head band alone. */}
{SWITCH_ONLY_CHANNELS.map(ch => (
<SettingsCard
key={ch.id}
icon={CHANNEL_ICONS[ch.id] ?? Smartphone}
title={t(ch.titleKey)}
hint={t(ch.hintKey)}
action={<ToggleSwitch on={channels.isActive(ch.id)} label={t(ch.titleKey)} onToggle={() => channels.toggle(ch.id)} />}
/>
))}
{/* In-App Panel */}
<SettingsCard
icon={Bell}
title={t('admin.notifications.inappPanel.title')}
hint={t('admin.notifications.inappPanel.hint')}
action={<LockedSwitch />}
/>
{/* Trip Reminders Toggle */}
<SettingsCard
icon={CalendarClock}
title={t('admin.notifications.tripReminders.title')}
hint={t('admin.notifications.tripReminders.hint')}
action={
<ToggleSwitch
on={tripRemindersActive}
label={t('admin.notifications.tripReminders.title')}
onToggle={async () => {
const next = !tripRemindersActive
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
try {
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
} catch {
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
toast.error(t('common.error'))
}
}}
/>
}
/>
</div>
{/* Trip Reminders Toggle */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.tripReminders.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.tripReminders.hint')}</p>
</div>
<button type="button"
onClick={async () => {
const next = !tripRemindersActive
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
try {
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
} catch {
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
toast.error(t('common.error'))
}
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ${tripRemindersActive ? 'bg-content' : 'bg-edge'}`}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: tripRemindersActive ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
</div>
<div className="space-y-6">
{/* Admin alerts are about running the instance (version notices, and what else
lands there later). On a managed install those go to whoever runs it. */}
{!managed && (<>
{/* Admin Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.notifications.adminWebhookPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminWebhookPanel.hint')}</p>
</div>
<div className="p-6 space-y-3">
{smtpLoaded && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminWebhookPanel.title')}</label>
<input
type="text"
value={smtpValues.admin_webhook_url === '••••••••' ? '' : smtpValues.admin_webhook_url || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))}
placeholder={smtpValues.admin_webhook_url === '••••••••' ? '••••••••' : 'https://discord.com/api/webhooks/...'}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
)}
</div>
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
<button type="button"
onClick={async () => {
try {
await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' })
toast.success(t('admin.notifications.adminWebhookPanel.saved'))
} catch { toast.error(t('common.error')) }
}}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
<Save className="w-4 h-4" />{t('common.save')}
</button>
<button type="button"
onClick={async () => {
// A masked value means the URL only lives on the server — send no url and let
// the server test the stored one instead of pre-saving the mask.
const url = smtpValues.admin_webhook_url === '••••••••' ? undefined : smtpValues.admin_webhook_url
try {
if (url) await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => {})
const result = await notificationsApi.testWebhook(url)
if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
} catch { toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) }
}}
disabled={!smtpValues.admin_webhook_url?.trim()}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
>
{t('admin.notifications.testWebhook')}
</button>
</div>
</div>
{/* Admin Ntfy Panel — same audience as the webhook above, and inside the
same wrapper. */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.notifications.adminNtfyPanel.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNtfyPanel.hint')}</p>
</div>
<div className="p-6 space-y-3">
{smtpLoaded && (
<>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.serverLabel')}</label>
<div className="min-w-0">
{/* Admin alerts are about running the instance (version notices, and what else
lands there later). On a managed install those go to whoever runs it. */}
{!managed && (<>
{/* Admin Webhook Panel */}
<SettingsCard icon={Webhook} title={t('admin.notifications.adminWebhookPanel.title')} hint={t('admin.notifications.adminWebhookPanel.hint')}>
{smtpLoaded && (
<EditorField label={t('admin.notifications.adminWebhookPanel.title')} htmlFor={`${ids}-admin-webhook`}>
<input
id={`${ids}-admin-webhook`}
type="text"
value={smtpValues.admin_webhook_url === MASK ? '' : smtpValues.admin_webhook_url || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_webhook_url: e.target.value }))}
placeholder={smtpValues.admin_webhook_url === MASK ? MASK : 'https://discord.com/api/webhooks/...'}
className={INPUT}
/>
</EditorField>
)}
<CardActions>
<button type="button"
onClick={async () => {
try {
await authApi.updateAppSettings({ admin_webhook_url: smtpValues.admin_webhook_url || '' })
toast.success(t('admin.notifications.adminWebhookPanel.saved'))
} catch { toast.error(t('common.error')) }
}}
className={SETTINGS_BUTTON_PRIMARY}>
<Save size={14} strokeWidth={2.2} />{t('common.save')}
</button>
<button type="button"
onClick={async () => {
// A masked value means the URL only lives on the server — send no url and let
// the server test the stored one instead of pre-saving the mask.
const url = smtpValues.admin_webhook_url === MASK ? undefined : smtpValues.admin_webhook_url
try {
if (url) await authApi.updateAppSettings({ admin_webhook_url: url }).catch(() => {})
const result = await notificationsApi.testWebhook(url)
if (result.success) toast.success(t('admin.notifications.adminWebhookPanel.testSuccess'))
else toast.error(result.error || t('admin.notifications.adminWebhookPanel.testFailed'))
} catch { toast.error(t('admin.notifications.adminWebhookPanel.testFailed')) }
}}
disabled={!smtpValues.admin_webhook_url?.trim()}
className={SETTINGS_BUTTON}
>
<Send size={14} strokeWidth={2.2} />{t('admin.notifications.testWebhook')}
</button>
</CardActions>
</SettingsCard>
{/* Admin Ntfy Panel — same audience as the webhook above, and inside the
same wrapper. */}
<SettingsCard icon={BellRing} title={t('admin.notifications.adminNtfyPanel.title')} hint={t('admin.notifications.adminNtfyPanel.hint')}>
{smtpLoaded && (<>
<EditorField label={t('admin.notifications.adminNtfyPanel.serverLabel')} htmlFor={`${ids}-ntfy-server`} hint={t('admin.notifications.adminNtfyPanel.serverHint')}>
<input
id={`${ids}-ntfy-server`}
type="text"
value={smtpValues.admin_ntfy_server || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_server: e.target.value }))}
placeholder={t('admin.notifications.adminNtfyPanel.serverPlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
className={INPUT}
/>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNtfyPanel.serverHint')}</p>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.topicLabel')}</label>
</EditorField>
<EditorField label={t('admin.notifications.adminNtfyPanel.topicLabel')} htmlFor={`${ids}-ntfy-topic`}>
<input
id={`${ids}-ntfy-topic`}
type="text"
value={smtpValues.admin_ntfy_topic || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_topic: e.target.value }))}
placeholder={t('admin.notifications.adminNtfyPanel.topicPlaceholder')}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
className={INPUT}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{t('admin.notifications.adminNtfyPanel.tokenLabel')}</label>
</EditorField>
<EditorField label={t('admin.notifications.adminNtfyPanel.tokenLabel')} htmlFor={`${ids}-ntfy-token`}>
<div className="flex gap-2">
<input
id={`${ids}-ntfy-token`}
type="password"
value={smtpValues.admin_ntfy_token === '••••••••' ? '' : smtpValues.admin_ntfy_token || ''}
value={smtpValues.admin_ntfy_token === MASK ? '' : smtpValues.admin_ntfy_token || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, admin_ntfy_token: e.target.value }))}
placeholder={smtpValues.admin_ntfy_token === '••••••••' ? '••••••••' : ''}
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
placeholder={smtpValues.admin_ntfy_token === MASK ? MASK : ''}
className={INPUT}
/>
{smtpValues.admin_ntfy_token === '••••••••' && (
{smtpValues.admin_ntfy_token === MASK && (
<button type="button"
onClick={async () => {
try {
@@ -307,69 +313,66 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
toast.success(t('admin.notifications.adminNtfyPanel.tokenCleared'))
} catch { toast.error(t('common.error')) }
}}
className="px-3 py-2 border border-red-300 text-red-600 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
style={fs(13, 'body')}
>
{t('common.clear')}
</button>
)}
</div>
</div>
</>
)}
</div>
<div className="px-6 pb-4 flex items-center gap-2 border-t border-slate-100 pt-4">
<button type="button"
onClick={async () => {
try {
await authApi.updateAppSettings({
admin_ntfy_server: smtpValues.admin_ntfy_server || '',
admin_ntfy_topic: smtpValues.admin_ntfy_topic || '',
...(smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== '••••••••'
? { admin_ntfy_token: smtpValues.admin_ntfy_token }
: {}),
})
toast.success(t('admin.notifications.adminNtfyPanel.saved'))
} catch { toast.error(t('common.error')) }
}}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors">
<Save className="w-4 h-4" />{t('common.save')}
</button>
<button type="button"
onClick={async () => {
const topic = smtpValues.admin_ntfy_topic?.trim()
if (!topic) return
try {
const token = smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== '••••••••'
? smtpValues.admin_ntfy_token : null
const result = await notificationsApi.testNtfy({
topic,
server: smtpValues.admin_ntfy_server || null,
token,
})
if (result.success) toast.success(t('admin.notifications.adminNtfyPanel.testSuccess'))
else toast.error(result.error || t('admin.notifications.adminNtfyPanel.testFailed'))
} catch { toast.error(t('admin.notifications.adminNtfyPanel.testFailed')) }
}}
disabled={!smtpValues.admin_ntfy_topic?.trim()}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
>
{t('admin.notifications.adminNtfyPanel.test')}
</button>
</div>
</div>
</>)}
</EditorField>
</>)}
<CardActions>
<button type="button"
onClick={async () => {
try {
await authApi.updateAppSettings({
admin_ntfy_server: smtpValues.admin_ntfy_server || '',
admin_ntfy_topic: smtpValues.admin_ntfy_topic || '',
...(smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== MASK
? { admin_ntfy_token: smtpValues.admin_ntfy_token }
: {}),
})
toast.success(t('admin.notifications.adminNtfyPanel.saved'))
} catch { toast.error(t('common.error')) }
}}
className={SETTINGS_BUTTON_PRIMARY}>
<Save size={14} strokeWidth={2.2} />{t('common.save')}
</button>
<button type="button"
onClick={async () => {
const topic = smtpValues.admin_ntfy_topic?.trim()
if (!topic) return
try {
const token = smtpValues.admin_ntfy_token && smtpValues.admin_ntfy_token !== MASK
? smtpValues.admin_ntfy_token : null
const result = await notificationsApi.testNtfy({
topic,
server: smtpValues.admin_ntfy_server || null,
token,
})
if (result.success) toast.success(t('admin.notifications.adminNtfyPanel.testSuccess'))
else toast.error(result.error || t('admin.notifications.adminNtfyPanel.testFailed'))
} catch { toast.error(t('admin.notifications.adminNtfyPanel.testFailed')) }
}}
disabled={!smtpValues.admin_ntfy_topic?.trim()}
className={SETTINGS_BUTTON}
>
<Send size={14} strokeWidth={2.2} />{t('admin.notifications.adminNtfyPanel.test')}
</button>
</CardActions>
</SettingsCard>
</>)}
{/* The matrix decides which of those channels each admin-only event goes out
over, so it belongs under the targets it routes to rather than across the
full width below both columns. */}
<AdminNotificationsPanel t={t} toast={toast} />
{/* The matrix decides which of those channels each admin-only event goes out
over, so it belongs under the targets it routes to rather than across the
full width below both columns. */}
<AdminNotificationsPanel t={t} toast={toast} />
</div>
</div>
{/* What users' own notifications start as, across the full width: one row per
user event, one column per channel users can pick (#1536). */}
<div className="mt-6">
<AdminNotificationDefaultsPanel />
</div>
<AdminNotificationDefaultsPanel />
</>)
}
@@ -24,9 +24,9 @@ function renderTab(overrides: Record<string, unknown> = {}) {
return admin as unknown as Spies;
}
/** The card element whose <h2> matches the given heading text. */
/** The card (a settingsKit SettingsCard, a <section>) whose <h2> matches the given heading text. */
function card(heading: string | RegExp): HTMLElement {
return screen.getByRole('heading', { name: heading }).closest<HTMLElement>('.rounded-xl')!;
return screen.getByRole('heading', { name: heading }).closest<HTMLElement>('section')!;
}
/**
@@ -38,9 +38,14 @@ function keyInput(name: string | RegExp): HTMLInputElement {
return within(card('API Keys')).getByLabelText(name).parentElement!.querySelector('input')!;
}
/** The toggle button in the row belonging to a label paragraph. */
/** The settingsKit row (label and hint left, control right) a label belongs to. */
function rowOf(el: HTMLElement): HTMLElement | null {
return el.tagName === 'LABEL' ? el.closest<HTMLElement>('.flex-wrap') : null;
}
/** The toggle button in the row belonging to a label. */
function toggleFor(label: string): HTMLElement {
const row = screen.getAllByText(label).map(el => el.closest<HTMLElement>('.flex.items-center.justify-between'));
const row = screen.getAllByText(label).map(rowOf);
const found = row.find(Boolean)!;
return within(found).getByRole('button');
}
@@ -468,7 +473,7 @@ describe('AdminSettingsTab', () => {
// A key alone does not put Google behind the search; the server ignores the
// switch under another provider, and the row must not promise otherwise.
renderTab({ placesGoogleOnly: true, hasMapsKey: true, placesProvider: 'openstreetmap' });
const hint = toggleFor('Search with Google only').closest<HTMLElement>('.flex.items-center.justify-between')!.querySelectorAll('p')[1];
const hint = toggleFor('Search with Google only').closest<HTMLElement>('.flex-wrap')!.querySelector('p')!;
expect(hint.textContent).not.toMatch(/Every search and every suggestion goes to Google Places/);
expect(hint.textContent).not.toMatch(/Needs a Google Maps API key/);
expect(hint.textContent).toMatch(/provider/i);
@@ -617,7 +622,7 @@ describe('AdminSettingsTab', () => {
transitGoogleKeySource: 'instance',
setTransitGoogleKeySource: vi.fn(),
});
const block = screen.getByText('Transit Provider').closest<HTMLElement>('.rounded-xl')!;
const block = screen.getByText('Transit Provider').closest<HTMLElement>('section')!;
fireEvent.click(within(block).getByRole('button'));
fireEvent.click(screen.getByText('Google'));
File diff suppressed because it is too large Load Diff
@@ -18,9 +18,9 @@ describe('AdminStatCard', () => {
expect(screen.getByText('0')).toBeInTheDocument();
});
it('FE-ADMSTAT-003: renders the passed icon component with the card icon classes', () => {
it('FE-ADMSTAT-003: renders the passed icon component in the card', () => {
const { container } = render(<AdminStatCard label="Trips" value={7} icon={Users} />);
expect(container.querySelector('svg.w-5.h-5.text-content')).toBeInTheDocument();
expect(container.querySelector('svg.lucide-users')).toBeInTheDocument();
});
});
+10 -8
View File
@@ -1,17 +1,19 @@
import React from 'react'
import { useCountUp } from '../../hooks/useCountUp'
import { fs } from '../../components/shared/DialogShell'
// Single animated metric card in the admin stats grid. Presentational.
// One animated metric of the admin header: a raised tile with the icon, the
// count and its label, sized to sit four in a row on the page's bar.
export default function AdminStatCard({ label, value, icon: Icon }: { label: string; value: number; icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }> }): React.ReactElement {
const animated = useCountUp(value, 900)
return (
<div className="rounded-xl border p-4 bg-surface-card border-edge">
<div className="flex items-center gap-4">
<Icon className="w-5 h-5 text-content" />
<div>
<p className="text-xl font-bold tabular-nums text-content">{animated}</p>
<p className="text-xs text-content-muted">{label}</p>
</div>
<div className="flex min-w-0 items-center gap-2.5 rounded-[12px] bg-surface-card py-2 pl-2 pr-3.5 shadow-sm">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary">
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="m-0 font-geist font-bold leading-tight tabular-nums text-content" style={fs(16, 'subtitle')}>{animated}</p>
<p className="m-0 truncate font-geist font-bold uppercase leading-tight tracking-[.08em] text-content-faint" style={fs(9.5)}>{label}</p>
</div>
</div>
)
+19 -26
View File
@@ -2,6 +2,8 @@ import React from 'react'
import { ArrowUpCircle, ExternalLink, Download } from 'lucide-react'
import type { TranslationFn } from '../../types'
import type { UpdateInfo } from './adminModel'
import { fs } from '../../components/shared/DialogShell'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY } from '../../components/Settings/settingsKit'
interface AdminUpdateBannerProps {
updateInfo: UpdateInfo
@@ -9,44 +11,35 @@ interface AdminUpdateBannerProps {
onHowTo: () => void
}
// The "new version available" banner shown at the top of the admin page.
// Purely presentational — extracted from AdminPage.
// The "new version available" banner shown at the top of the admin page, as a
// card of the settings kit: a warning tile, the versions, and the two actions
// in the dialogs' button shapes. Purely presentational.
export default function AdminUpdateBanner({ updateInfo, t, onHowTo }: AdminUpdateBannerProps): React.ReactElement {
return (
<div className="mb-6 p-4 rounded-xl border flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-amber-50 dark:bg-amber-950/40 border-amber-300 dark:border-amber-700">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center bg-amber-500 dark:bg-amber-600">
<ArrowUpCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-sm font-semibold text-amber-900 dark:text-amber-200">{t('admin.update.available')}</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}
</p>
</div>
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-2xl border border-edge-faint bg-surface-secondary px-4 py-3">
<span className="grid h-10 w-10 flex-none place-items-center rounded-[12px] bg-warning-soft text-warning">
<ArrowUpCircle size={18} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1 basis-64">
<p className="m-0 font-bold text-content" style={fs(14, 'body')}>{t('admin.update.available')}</p>
<p className="m-0 mt-0.5 text-content-muted" style={fs(12.5, 'body')}>
{t('admin.update.text').replace('{version}', `v${updateInfo.latest}`).replace('{current}', `v${updateInfo.current}`)}
</p>
</div>
{/* Both controls carry the same box metrics — padding, text size, icon, and a
border the filled one only needs to stop the outlined one from ending up
2px shorter. Two buttons side by side read as one group, so a size gap
reads as a mistake rather than as hierarchy; the fill, the colour and the
weight are what mark the primary action. */}
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex flex-none flex-wrap items-center gap-2" style={fs(13, 'body')}>
{updateInfo.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-600 hover:bg-amber-100 dark:hover:bg-amber-900/50"
className={SETTINGS_BUTTON}
>
<ExternalLink className="w-4 h-4 flex-shrink-0" />
<ExternalLink size={14} strokeWidth={2.1} className="flex-none" />
{t('admin.update.button')}
</a>
)}
<button type="button"
onClick={onHowTo}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-colors border border-transparent bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
>
<Download className="w-4 h-4 flex-shrink-0" />
<button type="button" onClick={onHowTo} className={SETTINGS_BUTTON_PRIMARY}>
<Download size={14} strokeWidth={2.1} className="flex-none" />
{t('admin.update.howTo')}
</button>
</div>
+16 -16
View File
@@ -3,7 +3,7 @@ import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { buildAdminHook, buildAdminUser, type AdminHook } from '../../../tests/helpers/mobileAdmin';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTranslation } from '../../i18n';
@@ -259,7 +259,10 @@ describe('AdminUserModals', () => {
it('FE-ADMMOD-018: the update popup closes from the Close button', () => {
const admin = renderModals({ showUpdateModal: true, updateInfo: buildUpdateInfo() });
fireEvent.click(screen.getByRole('button', { name: /close/i }));
// The head band's X and the footer's Close button both close it.
const closeButtons = screen.getAllByRole('button', { name: /^close$/i });
expect(closeButtons).toHaveLength(2);
fireEvent.click(closeButtons[closeButtons.length - 1]);
expect(admin.setShowUpdateModal).toHaveBeenCalledWith(false);
});
@@ -267,11 +270,14 @@ describe('AdminUserModals', () => {
it('FE-ADMMOD-019: the update popup closes on backdrop click but not on inner click', () => {
const admin = renderModals({ showUpdateModal: true, updateInfo: buildUpdateInfo() });
const inner = screen.getByText('How to Update').closest<HTMLElement>('div[style*="max-width"]')!;
const inner = screen.getByRole('dialog');
fireEvent.mouseDown(inner);
fireEvent.click(inner);
expect(admin.setShowUpdateModal).not.toHaveBeenCalled();
fireEvent.click(inner.parentElement!);
const backdrop = inner.parentElement!;
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(admin.setShowUpdateModal).toHaveBeenCalledWith(false);
});
@@ -330,10 +336,8 @@ describe('AdminUserModals', () => {
it('FE-ADMMOD-025: the modal X button closes the create-user modal', () => {
const admin = renderModals({ showCreateUser: true });
const header = screen
.getByRole('heading', { name: 'Create User' })
.closest<HTMLElement>('div.flex.items-center.justify-between')!;
fireEvent.click(within(header).getByRole('button'));
expect(screen.getByRole('heading', { name: 'Create User' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^close$/i }));
expect(admin.setShowCreateUser).toHaveBeenCalledWith(false);
});
@@ -341,10 +345,8 @@ describe('AdminUserModals', () => {
it('FE-ADMMOD-026: the modal X button closes the edit-user modal', () => {
const admin = renderModals(editing);
const header = screen
.getByRole('heading', { name: 'Edit User' })
.closest<HTMLElement>('div.flex.items-center.justify-between')!;
fireEvent.click(within(header).getByRole('button'));
expect(screen.getByRole('heading', { name: 'Edit User' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^close$/i }));
expect(admin.setEditingUser).toHaveBeenCalledWith(null);
});
@@ -352,10 +354,8 @@ describe('AdminUserModals', () => {
it('FE-ADMMOD-027: the modal X button closes the rotate-JWT modal', () => {
const admin = renderModals({ showRotateJwtModal: true });
const header = screen
.getByRole('heading', { name: 'Rotate JWT Secret' })
.closest<HTMLElement>('div.flex.items-center.justify-between')!;
fireEvent.click(within(header).getByRole('button'));
expect(screen.getByRole('heading', { name: 'Rotate JWT Secret' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^close$/i }));
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false);
});
+323 -351
View File
@@ -7,11 +7,26 @@ import {
EyeOff,
Fingerprint,
RefreshCw,
UserCog,
UserPlus,
} from 'lucide-react';
import React from 'react';
import React, { useId } from 'react';
import { adminApi } from '../../api/client';
import CustomSelect from '../../components/shared/CustomSelect';
import Modal from '../../components/shared/Modal';
import { Tooltip } from '../../components/shared/Tooltip';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogSection,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../components/shared/DialogShell';
import { EditorField, GRID_2, INPUT } from '../../components/shared/dialogParts';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER } from '../../components/Settings/settingsKit';
import type { TranslationFn } from '../../types';
import type { useAdmin } from './useAdmin';
import PasswordChecklist from '../../components/shared/PasswordChecklist';
@@ -21,8 +36,60 @@ interface AdminUserModalsProps {
t: TranslationFn;
}
/** The head band of a destructive question: a faint wash of the danger colour. */
const DANGER_TINT = 'color-mix(in srgb, var(--danger) 9%, transparent)';
/** The filled button that carries out a destructive action, as ConfirmDialog draws it. */
const DANGER_FILL =
'inline-flex items-center gap-1.5 rounded-[10px] bg-danger px-4 py-2 font-medium text-white hover:opacity-90 disabled:cursor-default disabled:opacity-50'; // theme-lint-disable: white on the danger fill, as ConfirmDialog draws it
/** A quiet framed note inside a dialog body. */
const NOTE = 'flex items-start gap-2.5 rounded-[12px] border border-edge-faint px-3.5 py-3';
/** A password field in the box look with the eye that shows what was typed. */
function PasswordInput({
id,
value,
onChange,
placeholder,
shown,
onToggle,
}: {
id: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
shown: boolean;
onToggle: () => void;
}) {
return (
<div className="relative">
<input
id={id}
type={shown ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`${INPUT} pr-10`}
/>
<span className="absolute right-1.5 top-1/2 flex -translate-y-1/2">
<Tooltip label="Show or hide password" placement="top">
<button
type="button"
onClick={onToggle}
tabIndex={-1}
aria-label="Show or hide password"
className="grid h-7 w-7 place-items-center rounded-[8px] text-content-faint hover:text-content"
>
{shown ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</Tooltip>
</span>
</div>
);
}
// The admin page's modal layer: create-user, edit-user, the "how to update"
// popup and the rotate-JWT confirmation. Pure layout around the useAdmin hook.
// popup and the rotate-JWT confirmation, all in the planner's dialog frame.
// Pure layout around the useAdmin hook.
export default function AdminUserModals({ admin, t }: AdminUserModalsProps): React.ReactElement {
const {
logout,
@@ -48,300 +115,257 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
} = admin;
const [showCreatePw, setShowCreatePw] = React.useState(false);
const [showEditPw, setShowEditPw] = React.useState(false);
const uid = useId();
const createId = `${uid}-create`;
const editId = `${uid}-edit`;
const updateId = `${uid}-update`;
const rotateId = `${uid}-rotate`;
const roleOptions = [
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
];
return (
<>
{/* Create user modal */}
<Modal
isOpen={showCreateUser}
<DialogShell
open={showCreateUser}
onClose={() => setShowCreateUser(false)}
title={t('admin.createUser')}
size="sm"
labelledBy={createId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<UserPlus size={20} strokeWidth={1.9} className="text-content-muted" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={createId}
onClose={() => setShowCreateUser(false)}
title={t('admin.createUser')}
/>
}
footer={
<div className="flex justify-end gap-3">
<button type="button"
onClick={() => setShowCreateUser(false)}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
>
{t('common.cancel')}
</button>
<button type="button"
onClick={handleCreateUser}
className="rounded-lg bg-slate-900 px-4 py-2 text-sm text-white hover:bg-slate-700"
>
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={() => setShowCreateUser(false)}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={handleCreateUser}>
{t('admin.createUser')}
</button>
</div>
</DialogButton>
</DialogFooter>
}
>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.username')} *</label>
<div className={GRID_2}>
<EditorField label={`${t('settings.username')} *`} htmlFor={`${createId}-username`}>
<input
id={`${createId}-username`}
type="text"
value={createForm.username}
onChange={(e) => setCreateForm((f) => ({ ...f, username: e.target.value }))}
placeholder={t('settings.username')}
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
className={INPUT}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('common.email')} *</label>
</EditorField>
<EditorField label={`${t('common.email')} *`} htmlFor={`${createId}-email`}>
<input
id={`${createId}-email`}
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))}
placeholder={t('common.email')}
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
className={INPUT}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('common.password')} *</label>
<div className="relative">
<input
type={showCreatePw ? 'text' : 'password'}
value={createForm.password}
onChange={(e) => setCreateForm((f) => ({ ...f, password: e.target.value }))}
placeholder={t('common.password')}
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 pr-10 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
/>
<button
type="button"
onClick={() => setShowCreatePw((v) => !v)}
tabIndex={-1}
aria-label="Show or hide password"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
>
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<PasswordChecklist password={createForm.password.trim()} className="mt-2" />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.role')}</label>
<CustomSelect
value={createForm.role}
onChange={(value) => setCreateForm((f) => ({ ...f, role: String(value) }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
]}
/>
</div>
</EditorField>
</div>
</Modal>
<EditorField label={`${t('common.password')} *`} htmlFor={`${createId}-password`}>
<PasswordInput
id={`${createId}-password`}
value={createForm.password}
onChange={(value) => setCreateForm((f) => ({ ...f, password: value }))}
placeholder={t('common.password')}
shown={showCreatePw}
onToggle={() => setShowCreatePw((v) => !v)}
/>
<PasswordChecklist password={createForm.password.trim()} className="mt-2" />
</EditorField>
<EditorField label={t('settings.role')}>
<CustomSelect
value={createForm.role}
onChange={(value) => setCreateForm((f) => ({ ...f, role: String(value) }))}
options={roleOptions}
/>
</EditorField>
</DialogShell>
{/* Edit user modal */}
<Modal
isOpen={!!editingUser}
<DialogShell
open={!!editingUser}
onClose={() => setEditingUser(null)}
title={t('admin.editUser')}
size="sm"
labelledBy={editId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<UserCog size={20} strokeWidth={1.9} className="text-content-muted" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={editId}
onClose={() => setEditingUser(null)}
title={t('admin.editUser')}
sub={editingUser?.email}
/>
}
footer={
<div className="flex justify-end gap-3">
<button type="button"
onClick={() => setEditingUser(null)}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
>
{t('common.cancel')}
</button>
<button type="button"
onClick={handleSaveUser}
className="rounded-lg bg-slate-900 px-4 py-2 text-sm text-white hover:bg-slate-700"
>
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={() => setEditingUser(null)}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={handleSaveUser}>
{t('common.save')}
</button>
</div>
</DialogButton>
</DialogFooter>
}
>
{editingUser && (
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.username')}</label>
<input
type="text"
value={editForm.username}
onChange={(e) => setEditForm((f) => ({ ...f, username: e.target.value }))}
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('common.email')}</label>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">
{t('admin.newPassword')}{' '}
<span className="font-normal text-slate-400">({t('admin.newPasswordHint')})</span>
</label>
<div className="relative">
<>
<div className={GRID_2}>
<EditorField label={t('settings.username')} htmlFor={`${editId}-username`}>
<input
type={showEditPw ? 'text' : 'password'}
value={editForm.password}
onChange={(e) => setEditForm((f) => ({ ...f, password: e.target.value }))}
placeholder={t('admin.newPasswordPlaceholder')}
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 pr-10 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
id={`${editId}-username`}
type="text"
value={editForm.username}
onChange={(e) => setEditForm((f) => ({ ...f, username: e.target.value }))}
className={INPUT}
/>
<button
type="button"
onClick={() => setShowEditPw((v) => !v)}
tabIndex={-1}
aria-label="Show or hide password"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
>
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<PasswordChecklist password={editForm.password.trim()} className="mt-2" />
</EditorField>
<EditorField label={t('common.email')} htmlFor={`${editId}-email`}>
<input
id={`${editId}-email`}
type="email"
value={editForm.email}
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
className={INPUT}
/>
</EditorField>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.role')}</label>
<EditorField
label={t('admin.newPassword')}
htmlFor={`${editId}-password`}
hint={t('admin.newPasswordHint')}
>
<PasswordInput
id={`${editId}-password`}
value={editForm.password}
onChange={(value) => setEditForm((f) => ({ ...f, password: value }))}
placeholder={t('admin.newPasswordPlaceholder')}
shown={showEditPw}
onToggle={() => setShowEditPw((v) => !v)}
/>
<PasswordChecklist password={editForm.password.trim()} className="mt-2" />
</EditorField>
<EditorField label={t('settings.role')}>
<CustomSelect
value={editForm.role}
onChange={(value) => setEditForm((f) => ({ ...f, role: String(value) }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
]}
options={roleOptions}
/>
</div>
<div className="border-t border-slate-100 pt-3">
<p className="mb-2 text-xs text-slate-400">{t('admin.passkey.resetHint')}</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'));
}
}}
className="flex items-center gap-2 rounded-lg border border-red-200 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
>
<Fingerprint size={14} /> {t('admin.passkey.reset')}
</button>
</div>
</div>
</EditorField>
<DialogSection label={t('admin.passkey.reset')}>
<div className="flex flex-wrap items-center gap-3 rounded-[12px] border border-edge-faint bg-surface-secondary px-3.5 py-3">
<p className="m-0 min-w-0 flex-1 basis-52 leading-snug text-content-muted" style={fs(12, 'body')}>
{t('admin.passkey.resetHint')}
</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'));
}
}}
className={`${SETTINGS_BUTTON_DANGER} flex-none`}
style={fs(12.5, 'body')}
>
<Fingerprint size={14} /> {t('admin.passkey.reset')}
</button>
</div>
</DialogSection>
</>
)}
</Modal>
</DialogShell>
{/* Update instructions popup */}
{showUpdateModal && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
role="presentation"
onClick={() => setShowUpdateModal(false)}
>
<div
role="presentation"
onClick={(e) => e.stopPropagation()}
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
className="border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
<DialogShell
open={showUpdateModal}
onClose={() => setShowUpdateModal(false)}
labelledBy={updateId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<ArrowUpCircle size={20} strokeWidth={1.9} className="text-warning" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={updateId}
onClose={() => setShowUpdateModal(false)}
title={t('admin.update.howTo')}
sub={`v${updateInfo?.current} → v${updateInfo?.latest}`}
/>
}
footer={
<DialogFooter>
{updateInfo?.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className={SETTINGS_BUTTON}
style={fs(13, 'body')}
>
<ExternalLink size={14} strokeWidth={2.1} className="flex-none" />
{t('admin.update.button')}
</a>
)}
<FooterSpacer />
<DialogButton variant="primary" onClick={() => setShowUpdateModal(false)}>
{t('common.close')}
</DialogButton>
</DialogFooter>
}
>
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace(
'{version}',
`v${updateInfo?.latest ?? ''}`
)}
</p>
{updateInfo?.is_docker === false ? (
<a
href="https://github.com/liketrek/TREK/wiki/Updating"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 rounded-[12px] border border-edge-faint bg-surface-secondary px-3.5 py-3 text-content no-underline transition-colors hover:bg-surface-tertiary"
style={fs(13, 'body')}
>
<div
style={{
background: 'linear-gradient(135deg, #0f172a, #1e293b)',
padding: '20px 24px',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<div
className="bg-[rgba(255,255,255,0.2)]"
style={{
width: 40,
height: 40,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<ArrowUpCircle size={20} className="text-white" />
</div>
<div>
<h3
className="text-white"
style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}
>
{t('admin.update.howTo')}
</h3>
<p
className="text-[rgba(255,255,255,0.8)]"
style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}
>
v{updateInfo?.current} → v{updateInfo?.latest}
</p>
</div>
</div>
<div style={{ padding: '20px 24px' }}>
<p
className="text-gray-700 dark:text-gray-300"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}
>
{(updateInfo?.is_docker === false
? t('admin.update.nonDockerText')
: t('admin.update.dockerText')
).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
</p>
{updateInfo?.is_docker === false ? (
<a
href="https://github.com/liketrek/TREK/wiki/Updating"
target="_blank"
rel="noopener noreferrer"
style={{
marginTop: 14,
padding: '12px 14px',
borderRadius: 10,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
lineHeight: 1.5,
display: 'flex',
alignItems: 'center',
gap: 8,
textDecoration: 'none',
}}
className="border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
<ExternalLink className="h-4 w-4 flex-shrink-0" />
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
</a>
) : (
<div
style={{
marginTop: 14,
padding: '12px 14px',
borderRadius: 10,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
lineHeight: 1.8,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
className="border border-gray-700 bg-gray-900 text-gray-100 dark:bg-gray-950"
>
{`docker pull mauriceboe/trek:latest
<ExternalLink className="h-4 w-4 flex-shrink-0 text-content-muted" />
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
</a>
) : (
<pre
className="m-0 whitespace-pre-wrap break-all rounded-[12px] border border-edge-faint bg-surface-tertiary px-3.5 py-3 font-mono leading-[1.8] text-content"
style={fs(12, 'body')}
>
{`docker pull mauriceboe/trek:latest
docker stop trek && docker rm trek
docker run -d --name trek \\
-p 3000:3000 \\
@@ -349,90 +373,42 @@ docker run -d --name trek \\
-v /opt/trek/uploads:/app/uploads \\
--restart unless-stopped \\
mauriceboe/trek:latest`}
</div>
)}
</pre>
)}
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
lineHeight: 1.5,
}}
className="border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300"
>
<div className="flex items-start gap-2">
<CheckCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{t('admin.update.dataInfo')}</span>
</div>
</div>
{updateInfo?.release_url && (
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
lineHeight: 1.5,
}}
className="border border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
<div className="flex items-start gap-2">
<ExternalLink className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="font-semibold underline"
>
{t('admin.update.button')}
</a>
</span>
</div>
</div>
)}
</div>
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end' }}>
<button type="button"
onClick={() => setShowUpdateModal(false)}
className="bg-slate-900 text-white hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-gray-200"
style={{
padding: '9px 20px',
borderRadius: 10,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontWeight: 600,
border: 'none',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
{t('common.close')}
</button>
</div>
</div>
<div className={`${NOTE} bg-success-soft text-success`} style={fs(12, 'body')}>
<CheckCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span className="leading-normal">{t('admin.update.dataInfo')}</span>
</div>
)}
</DialogShell>
{/* Rotate JWT Secret confirmation modal */}
<Modal
isOpen={showRotateJwtModal}
<DialogShell
open={showRotateJwtModal}
onClose={() => setShowRotateJwtModal(false)}
title="Rotate JWT Secret"
size="sm"
labelledBy={rotateId}
width="narrow"
header={
<DialogHeader
tile={
<DialogTile>
<AlertTriangle size={20} strokeWidth={1.9} className="text-danger" />
</DialogTile>
}
tint={DANGER_TINT}
labelId={rotateId}
onClose={() => setShowRotateJwtModal(false)}
title="Rotate JWT Secret"
/>
}
footer={
<div className="flex justify-end gap-3">
<button type="button"
onClick={() => setShowRotateJwtModal(false)}
disabled={rotatingJwt}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 disabled:opacity-50"
>
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={() => setShowRotateJwtModal(false)} disabled={rotatingJwt}>
{t('common.cancel')}
</button>
<button type="button"
</DialogButton>
<button
type="button"
onClick={async () => {
setRotatingJwt(true);
try {
@@ -446,33 +422,29 @@ docker run -d --name trek \\
}
}}
disabled={rotatingJwt}
className="flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:bg-red-300"
className={DANGER_FILL}
style={fs(13, 'body')}
>
{rotatingJwt ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<RefreshCw className="h-4 w-4" />
<RefreshCw className="h-3.5 w-3.5" />
)}
Rotate &amp; Log out
</button>
</div>
</DialogFooter>
}
>
<div className="flex gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-red-100">
<AlertTriangle className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="mb-1 text-sm font-medium text-slate-900">
Warning, this will invalidate all sessions and log you out.
</p>
<p className="text-xs text-slate-500">
A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out
and will need to log in again.
</p>
</div>
<div className="flex flex-col gap-1.5">
<p className="m-0 font-semibold text-content" style={fs(13.5, 'body')}>
Warning, this will invalidate all sessions and log you out.
</p>
<p className="m-0 leading-normal text-content-muted" style={fs(12.5, 'body')}>
A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out
and will need to log in again.
</p>
</div>
</Modal>
</DialogShell>
</>
);
}
+8 -10
View File
@@ -119,7 +119,7 @@ describe('AdminUsersTab', () => {
it('FE-ADMUSR-008: edit button forwards the row user to handleEditUser', () => {
const admin = renderTab({ users: [me, alice], currentUser: me });
const editButtons = screen.getAllByTitle('Edit User');
const editButtons = screen.getAllByLabelText('Edit User');
fireEvent.click(editButtons[1]);
expect(admin.handleEditUser).toHaveBeenCalledWith(alice);
@@ -128,7 +128,7 @@ describe('AdminUsersTab', () => {
it('FE-ADMUSR-009: delete button forwards the row user to handleDeleteUser', () => {
const admin = renderTab({ users: [me, alice], currentUser: me });
const deleteButtons = screen.getAllByTitle('Delete user');
const deleteButtons = screen.getAllByLabelText('Delete user');
fireEvent.click(deleteButtons[1]);
expect(admin.handleDeleteUser).toHaveBeenCalledWith(alice);
@@ -137,7 +137,7 @@ describe('AdminUsersTab', () => {
it('FE-ADMUSR-010: the delete button of the current user is disabled', () => {
renderTab({ users: [me, alice], currentUser: me });
const deleteButtons = screen.getAllByTitle('Delete user');
const deleteButtons = screen.getAllByLabelText('Delete user');
expect(deleteButtons[0]).toBeDisabled();
expect(deleteButtons[1]).not.toBeDisabled();
});
@@ -167,7 +167,7 @@ describe('AdminUsersTab', () => {
renderTab({ invites: [buildInvite({ expires_at: '2020-01-01T00:00:00.000Z' })] });
expect(screen.getByText('Expired')).toBeInTheDocument();
expect(screen.queryByTitle('Copy link')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument();
});
it('FE-ADMUSR-015: marks an invite at its use limit as used up', () => {
@@ -185,10 +185,10 @@ describe('AdminUsersTab', () => {
it('FE-ADMUSR-017: copy and delete buttons forward the invite token / id', () => {
const admin = renderTab({ invites: [buildInvite()] });
fireEvent.click(screen.getByTitle('Copy link'));
fireEvent.click(screen.getByLabelText('Copy link'));
expect(admin.copyInviteLink).toHaveBeenCalledWith('abcdefghijkl-rest-of-token');
fireEvent.click(screen.getByTitle('Delete'));
fireEvent.click(screen.getByLabelText('Delete'));
expect(admin.handleDeleteInvite).toHaveBeenCalledWith(10);
});
@@ -249,10 +249,8 @@ describe('AdminUsersTab', () => {
it('FE-ADMUSR-024: the invite modal X button closes it', () => {
const admin = renderTab({ showCreateInvite: true });
const header = screen
.getByRole('heading', { name: 'Create Link' })
.closest<HTMLElement>('div.flex.items-center.justify-between')!;
fireEvent.click(within(header).getByRole('button'));
expect(screen.getByRole('heading', { name: 'Create Link' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^close$/i }));
expect(admin.setShowCreateInvite).toHaveBeenCalledWith(false);
});
+190 -181
View File
@@ -1,8 +1,11 @@
import React from 'react'
import React, { useId, type ReactNode } from 'react'
import CustomSelect from '../../components/shared/CustomSelect'
import { Shield, Trash2, Edit2, UserPlus, Link2, Copy, Plus } from 'lucide-react'
import Modal from '../../components/shared/Modal'
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 { 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'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
@@ -12,6 +15,21 @@ interface AdminUsersTabProps {
locale: string
}
const TH = 'px-3.5 py-2.5 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const TD = 'px-3.5 py-3 align-middle'
/** An icon action of a row, named by its tooltip and its aria-label. */
function RowIconButton({ label, onClick, disabled, danger, children }: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean; children: ReactNode }) {
return (
<Tooltip label={label} disabled={disabled}>
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
className={`${SETTINGS_ICON_BUTTON} ${danger ? 'hover:!text-danger' : ''} disabled:!opacity-30 disabled:cursor-not-allowed`}>
{children}
</button>
</Tooltip>
)
}
// "Users" admin tab: user table, invite links, permissions panel + the
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
@@ -23,227 +41,218 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
copyInviteLink, handleCreateInvite, handleDeleteInvite,
handleEditUser, handleDeleteUser,
} = admin
const inviteLabelId = useId()
return (
<>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')}</h2>
<p className="text-xs text-slate-400 mt-1">{users.length} {t('admin.stats.users')}</p>
</div>
<button type="button"
onClick={() => setShowCreateUser(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
<UserPlus className="w-4 h-4" />
<SettingsCard
icon={Users}
title={t('admin.tabs.users')}
hint={<>{users.length} {t('admin.stats.users')}</>}
action={
<button type="button" onClick={() => setShowCreateUser(true)} className={SETTINGS_BUTTON_PRIMARY} style={fs(12.5, 'body')}>
<UserPlus size={14} strokeWidth={2.1} />
{t('admin.createUser')}
</button>
</div>
}
>
{isLoading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto"></div>
<div className="grid place-items-center py-10">
<div className="h-7 w-7 animate-spin rounded-full border-2 border-edge border-t-[color:var(--text-primary)]" />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<div className="overflow-x-auto rounded-[12px] border border-edge-faint bg-surface-card">
<table className="w-full border-collapse">
<thead>
<tr className="text-left text-xs font-medium text-slate-500 uppercase tracking-wider border-b border-slate-100 bg-slate-50">
<th className="px-5 py-3">{t('admin.table.user')}</th>
<th className="px-5 py-3">{t('admin.table.email')}</th>
<th className="px-5 py-3">{t('admin.table.role')}</th>
<th className="px-5 py-3">{t('admin.table.created')}</th>
<th className="px-5 py-3">{t('admin.table.lastLogin')}</th>
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
<tr className="border-b border-edge-faint text-left" style={fs(9.5)}>
<th className={TH}>{t('admin.table.user')}</th>
<th className={TH}>{t('admin.table.email')}</th>
<th className={TH}>{t('admin.table.role')}</th>
<th className={TH}>{t('admin.table.created')}</th>
<th className={TH}>{t('admin.table.lastLogin')}</th>
<th className={`${TH} text-right`}>{t('admin.table.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 trek-stagger">
{users.map(u => (
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<div className="relative">
{u.avatar_url ? (
<img src={u.avatar_url} alt={u.username} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
{u.username.charAt(0).toUpperCase()}
</div>
)}
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-surface-card ${u.online ? 'bg-[#22c55e]' : 'bg-[#94a3b8]'}`} />
<tbody className="trek-stagger divide-y divide-edge-faint">
{users.map(u => {
const isMe = u.id === currentUser?.id
return (
<tr key={u.id} className={`transition-colors hover:bg-surface-secondary ${isMe ? 'bg-surface-secondary' : ''}`}>
<td className={TD}>
<div className="flex min-w-0 items-center gap-2.5">
<div className="relative flex-none">
{u.avatar_url ? (
<img src={u.avatar_url} alt={u.username} className="h-8 w-8 rounded-full object-cover" />
) : (
<div className="grid h-8 w-8 place-items-center rounded-full bg-surface-tertiary font-semibold text-content-secondary" style={fs(13, 'body')}>
{u.username.charAt(0).toUpperCase()}
</div>
)}
<span className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-card ${u.online ? 'bg-success' : 'bg-content-faint'}`} />
</div>
<div className="min-w-0">
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{u.username}</p>
{isMe && (
<span className="text-content-faint" style={fs(11.5)}>{t('admin.you')}</span>
)}
</div>
</div>
<div>
<p className="text-sm font-medium text-slate-900">{u.username}</p>
{u.id === currentUser?.id && (
<span className="text-xs text-slate-500">{t('admin.you')}</span>
)}
</td>
<td className={`${TD} text-content-secondary`} style={fs(12.5, 'body')}>
<div className="max-w-[260px] truncate">{u.email}</div>
</td>
<td className={TD}>
{u.role === 'admin'
? <StatusPill tone="accent" icon={<Shield size={10} strokeWidth={2.4} />}>{t('settings.roleAdmin')}</StatusPill>
: <StatusPill>{t('settings.roleUser')}</StatusPill>}
</td>
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`} style={fs(12)}>
{new Date(u.created_at).toLocaleDateString(locale)}
</td>
<td className={`${TD} whitespace-nowrap font-geist tabular-nums text-content-muted`} style={fs(12)}>
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
</td>
<td className={TD}>
<div className="flex items-center justify-end gap-1.5">
<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>
<Trash2 size={14} strokeWidth={2} />
</RowIconButton>
</div>
</div>
</td>
<td className="px-5 py-3 text-sm text-slate-600">{u.email}</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full ${
u.role === 'admin'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}>
{u.role === 'admin' && <Shield className="w-3 h-3" />}
{u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString(locale)}
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end">
<button type="button"
onClick={() => handleEditUser(u)}
className="p-1.5 text-slate-400 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
title={t('admin.editUser')}
>
<Edit2 className="w-4 h-4" />
</button>
<button type="button"
onClick={() => handleDeleteUser(u)}
disabled={u.id === currentUser?.id}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title={t('admin.deleteUserTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</SettingsCard>
{/* Invite Links (inside users tab) */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden mt-6">
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 className="font-semibold text-slate-900">{t('admin.invite.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.invite.subtitle')}</p>
</div>
<button type="button"
onClick={() => setShowCreateInvite(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
<Plus className="w-4 h-4" />
<SettingsCard
icon={Link2}
title={t('admin.invite.title')}
hint={t('admin.invite.subtitle')}
action={
<button type="button" onClick={() => setShowCreateInvite(true)} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
<Plus size={14} strokeWidth={2.2} />
{t('admin.invite.create')}
</button>
</div>
}
>
{invites.length === 0 ? (
<div className="p-8 text-center text-sm text-slate-400">{t('admin.invite.empty')}</div>
<SettingsHint className="py-4 text-center">{t('admin.invite.empty')}</SettingsHint>
) : (
<div className="divide-y divide-slate-100">
<SettingRows>
{invites.map(inv => {
const isExpired = inv.expires_at && new Date(inv.expires_at) < new Date()
const isUsedUp = inv.max_uses > 0 && inv.used_count >= inv.max_uses
const isActive = !isExpired && !isUsedUp
return (
<div key={inv.id} className="px-5 py-3 flex items-center gap-4">
<Link2 className={`w-4 h-4 flex-shrink-0 ${isActive ? 'text-content' : 'text-[#d1d5db]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-slate-600 truncate">{inv.token.slice(0, 12)}...</code>
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
isActive ? 'bg-green-50 text-green-700' : 'bg-slate-100 text-slate-400'
}`}>
<div key={inv.id} className={`flex items-center gap-3 px-3.5 py-3 ${isActive ? '' : 'opacity-70'}`}>
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary ${isActive ? 'text-content-secondary' : 'text-content-faint'}`}>
<Link2 size={14} strokeWidth={2} />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<code className="min-w-0 truncate font-geist font-semibold text-content" style={fs(12.5, 'body')}>{inv.token.slice(0, 12)}...</code>
<StatusPill tone={isActive ? 'success' : 'neutral'}>
{isUsedUp ? t('admin.invite.usedUp') : isExpired ? t('admin.invite.expired') : t('admin.invite.active')}
</span>
</StatusPill>
</div>
<div className="text-xs text-slate-400 mt-0.5">
<div className="mt-0.5 truncate text-content-faint" style={fs(11.5)}>
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
{inv.trip_title && ` · ${t('admin.invite.boundTo', { trip: inv.trip_title })}`}
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
</div>
</div>
{isActive && (
<button type="button" onClick={() => copyInviteLink(inv.token)} title={t('admin.invite.copyLink')}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors">
<Copy className="w-3.5 h-3.5" />
</button>
)}
<button type="button" onClick={() => handleDeleteInvite(inv.id)} title={t('common.delete')}
className="p-1.5 rounded-lg hover:bg-red-50 text-slate-400 hover:text-red-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
<div className="flex flex-none items-center gap-1.5">
{isActive && (
<RowIconButton label={t('admin.invite.copyLink')} onClick={() => copyInviteLink(inv.token)}>
<Copy size={14} strokeWidth={2} />
</RowIconButton>
)}
<RowIconButton label={t('common.delete')} onClick={() => handleDeleteInvite(inv.id)} danger>
<Trash2 size={14} strokeWidth={2} />
</RowIconButton>
</div>
</div>
)
})}
</div>
</SettingRows>
)}
</div>
</SettingsCard>
<div className="mt-6"><PermissionsPanel /></div>
<PermissionsPanel />
{/* Create Invite Modal */}
<Modal isOpen={showCreateInvite} onClose={() => setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.maxUses')}</label>
<div className="flex gap-2">
{[1, 2, 3, 4, 5, 0].map(n => (
<button key={n} type="button" onClick={() => setInviteForm(f => ({ ...f, max_uses: n }))}
className={`flex-1 py-2 rounded-lg text-sm font-semibold border transition-colors ${
inviteForm.max_uses === n ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
}`}>
{n === 0 ? '∞' : `${n}×`}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.expiry')}</label>
<div className="flex gap-2">
{[
{ value: 1, label: '1d' },
{ value: 3, label: '3d' },
{ value: 7, label: '7d' },
{ value: 14, label: '14d' },
{ value: '', label: '∞' },
].map(opt => (
<button key={String(opt.value)} type="button" onClick={() => setInviteForm(f => ({ ...f, expires_in_days: opt.value as number | '' }))}
className={`flex-1 py-2 rounded-lg text-sm font-semibold border transition-colors ${
inviteForm.expires_in_days === opt.value ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-200 hover:border-slate-400'
}`}>
{opt.label}
</button>
))}
</div>
</div>
{inviteTrips.length > 0 && (
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{t('admin.invite.tripLabel')}</label>
<CustomSelect
value={inviteForm.trip_id}
onChange={v => setInviteForm(f => ({ ...f, trip_id: v === '' ? '' : Number(v) }))}
options={[
{ value: '', label: t('admin.invite.tripNone') },
...inviteTrips.map(tr => ({ value: tr.id, label: tr.title })),
]}
searchable={inviteTrips.length > 8}
placeholder={t('admin.invite.tripNone')}
/>
<p className="text-xs text-slate-400 mt-1.5 leading-relaxed">{t('admin.invite.tripHint')}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
<button type="button" onClick={() => setShowCreateInvite(false)} className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700">{t('common.cancel')}</button>
<button type="button" onClick={handleCreateInvite} className="px-4 py-2 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700">{t('admin.invite.createAndCopy')}</button>
</div>
</div>
</Modal>
<DialogShell
open={showCreateInvite}
onClose={() => setShowCreateInvite(false)}
labelledBy={inviteLabelId}
width="narrow"
header={
<DialogHeader
tile={<DialogTile><Link2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={inviteLabelId}
onClose={() => setShowCreateInvite(false)}
title={t('admin.invite.create')}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={() => setShowCreateInvite(false)}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={handleCreateInvite} icon={<Copy size={14} strokeWidth={2.1} />}>
{t('admin.invite.createAndCopy')}
</DialogButton>
</DialogFooter>
}
>
<EditorField label={t('admin.invite.maxUses')}>
<Segmented
label={t('admin.invite.maxUses')}
fill
value={String(inviteForm.max_uses)}
onChange={v => setInviteForm(f => ({ ...f, max_uses: Number(v) }))}
options={[1, 2, 3, 4, 5, 0].map(n => ({ value: String(n), label: n === 0 ? '∞' : `${n}×` }))}
/>
</EditorField>
<EditorField label={t('admin.invite.expiry')}>
<Segmented
label={t('admin.invite.expiry')}
fill
value={inviteForm.expires_in_days === '' ? 'never' : String(inviteForm.expires_in_days)}
onChange={v => setInviteForm(f => ({ ...f, expires_in_days: v === 'never' ? '' : Number(v) }))}
options={[
{ value: '1', label: '1d' },
{ value: '3', label: '3d' },
{ value: '7', label: '7d' },
{ value: '14', label: '14d' },
{ value: 'never', label: '∞' },
]}
/>
</EditorField>
{inviteTrips.length > 0 && (
<EditorField label={t('admin.invite.tripLabel')} hint={t('admin.invite.tripHint')}>
<CustomSelect
value={inviteForm.trip_id}
onChange={v => setInviteForm(f => ({ ...f, trip_id: v === '' ? '' : Number(v) }))}
options={[
{ value: '', label: t('admin.invite.tripNone') },
...inviteTrips.map(tr => ({ value: tr.id, label: tr.title })),
]}
searchable={inviteTrips.length > 8}
placeholder={t('admin.invite.tripNone')}
/>
</EditorField>
)}
</DialogShell>
</>
)
}
+13 -8
View File
@@ -1,5 +1,7 @@
import React from 'react'
import { ChevronRight } from 'lucide-react'
import { fs } from '../../components/shared/DialogShell'
import { StatusPill } from '../../components/Settings/settingsKit'
interface GoogleOptionsProps {
/** "Google options" — the heading of the fold. */
@@ -19,24 +21,27 @@ interface GoogleOptionsProps {
*
* A native <details> rather than a state hook: keyboard operable and announced
* to screen readers without writing any of that, and it survives a re-render
* without state.
* without state. Drawn as a box of rows (settingsKit's SettingRows), the
* summary being its first row.
*/
export default function GoogleOptions({ title, summary, children }: GoogleOptionsProps): React.ReactElement {
return (
<details className="group rounded-lg border border-edge-secondary bg-surface-secondary/40">
<details className="group overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
<summary
className="flex cursor-pointer list-none items-center gap-2 px-4 py-2.5
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent
className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5 hover:bg-surface-hover
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)]
[&::-webkit-details-marker]:hidden"
>
<ChevronRight
className="h-4 w-4 flex-shrink-0 text-content-faint transition-transform group-open:rotate-90"
size={15}
strokeWidth={2}
className="flex-none text-content-faint transition-transform group-open:rotate-90"
aria-hidden="true"
/>
<span className="flex-1 text-sm font-medium text-content-secondary">{title}</span>
<span className="text-xs text-content-faint">{summary}</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(13, 'body')}>{title}</span>
<StatusPill>{summary}</StatusPill>
</summary>
<div className="border-t border-edge-faint px-4">{children}</div>
<div className="divide-y divide-edge-faint border-t border-edge-faint bg-surface-card">{children}</div>
</details>
)
}
+11 -22
View File
@@ -1,8 +1,10 @@
import React from 'react'
import { fs } from '../../components/shared/DialogShell'
import { StatusPill } from '../../components/Settings/settingsKit'
interface ProviderBlockProps {
title: string
/** Optional label sitting on the top border, like the one on the TREK block. */
/** Optional pill beside the title, like the one on the TREK block. */
badge?: string
/**
* `accent` marks the recommended path, `muted` a neutral one, `caution` a
@@ -17,32 +19,19 @@ interface ProviderBlockProps {
/**
* One provider inside the API card, in the same shape as the TREK block above
* it, so the page reads as a list of comparable options rather than one
* highlighted thing and some loose fields underneath.
* highlighted thing and some loose fields underneath. A white box on the
* card's grey body, its name in a head row, like a bag in the packing sidebar.
*/
export default function ProviderBlock({
title, badge, tone = 'muted', children,
}: ProviderBlockProps): React.ReactElement {
return (
<div className="relative">
{badge && (
<span
className={`pointer-events-none absolute -top-2 z-10 select-none rounded-md px-2 py-0.5
text-[10px] font-medium uppercase tracking-[0.14em] shadow-sm
ltr:left-4 rtl:right-4 ${
tone === 'caution'
? 'bg-warning-soft text-warning border border-warning/30'
: 'bg-surface-tertiary text-content-faint border border-edge-secondary'
}`}
>
{badge}
</span>
)}
<div className="overflow-hidden rounded-xl border border-edge bg-surface-secondary/30">
<div className="px-5 pt-6 pb-5">
<p className="text-sm font-medium text-content-secondary">{title}</p>
<div className="mt-3 space-y-4">{children}</div>
</div>
<section className="overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<div className="flex min-w-0 items-center gap-2 border-b border-edge-faint px-3.5 py-2.5">
<h3 className="m-0 min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>{title}</h3>
{badge && <StatusPill tone={tone === 'caution' ? 'warning' : 'neutral'}>{badge}</StatusPill>}
</div>
</div>
<div className="flex flex-col gap-3 p-3.5">{children}</div>
</section>
)
}
+72 -92
View File
@@ -1,6 +1,8 @@
import React from 'react'
import { Check, ChevronRight, Globe2, KeyRound, Library, ShieldOff, WifiOff, X } from 'lucide-react'
import TrekMark from '../../components/shared/TrekMark'
import { fs } from '../../components/shared/DialogShell'
import { StatusPill } from '../../components/Settings/settingsKit'
import type { TranslationFn } from '../../types'
interface TrekApiCardProps {
@@ -11,9 +13,9 @@ interface TrekApiCardProps {
* The TREK Places API, above the Google key because it is the alternative to it.
*
* Given real weight on the page on purpose: it is what makes a key optional
* instead of expected. The weight comes from the mark, one line of type and a
* ring — not from a tinted panel, which is what the weather block used to do
* and what made it shout over the settings it sat next to.
* instead of expected. The weight comes from the mark, one line of type and an
* accent ring — not from a tinted panel, which is what the weather block used
* to do and what made it shout over the settings it sat next to.
*
* Everything a reader can check is a measured number, not a claim: 73.6 million
* is the row count of the current index, and "no queries logged" is enforced in
@@ -26,6 +28,22 @@ interface TrekApiCardProps {
*/
const SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']
const CHIP = 'rounded-full border px-2.5 py-[3px] font-medium'
/** One of the three lists under "more": an icon and a name, chips, a note. */
function FactGroup({ icon, title, note, children }: { icon: React.ReactNode; title: string; note: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2 px-3.5 py-3">
<p className="m-0 flex items-center gap-1.5 font-semibold text-content" style={fs(12.5, 'body')}>
{icon}
{title}
</p>
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0" style={fs(11.5)}>{children}</ul>
<p className="m-0 leading-normal text-content-faint" style={fs(11.5)}>{note}</p>
</div>
)
}
export default function TrekApiCard({ t }: TrekApiCardProps): React.ReactElement {
// Reuses the words TREK already has for these fields wherever it has them,
// so the chip row costs two new strings instead of ten.
@@ -50,38 +68,29 @@ export default function TrekApiCard({ t }: TrekApiCardProps): React.ReactElement
]
return (
/* Relative wrapper without overflow so the ribbon may sit ON the edge; the
clipping the rounded corners need happens one level in. */
<div className="relative">
{/* The recommendation, moved here from the Google field. It is the whole
point of the block: a key should be the exception, not the default.
Vertical and small on purpose — a wide coloured banner would shout
over the settings around it, which is what the weather panel used to
do and what this card is trying not to repeat. */}
<span
className="pointer-events-none absolute -top-2 z-10 select-none rounded-md
bg-accent px-2 py-0.5 text-[10px] font-medium uppercase
tracking-[0.14em] text-accent-text shadow-sm
ltr:left-4 rtl:right-4"
>
{t('admin.trekApi.badgeDefault')}
</span>
<div className="overflow-hidden rounded-xl border border-accent/40 bg-surface-secondary/50 shadow-sm">
<div className="px-5 pt-6 pb-4">
<div className="flex items-start justify-between gap-4">
<TrekMark className="h-8 w-auto text-content" aria-label="TREK Places API" />
<div className="overflow-hidden rounded-[14px] border bg-surface-card shadow-sm"
style={{ borderColor: 'color-mix(in srgb, var(--accent) 40%, transparent)' }}>
<div className="flex flex-col gap-3 px-3.5 pb-3.5 pt-3.5">
{/* The recommendation, moved here from the Google field. It is the whole
point of the block: a key should be the exception, not the default.
A pill beside the mark rather than a banner, so it does not shout
over the settings around it. */}
<div className="flex flex-wrap items-center justify-between gap-2">
<TrekMark className="h-7 w-auto text-content" aria-label="TREK Places API" />
<StatusPill tone="accent">{t('admin.trekApi.badgeDefault')}</StatusPill>
</div>
<p className="mt-3 text-sm leading-relaxed text-content-secondary">
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
{t('admin.trekApi.tagline')}
</p>
<ul className="mt-4 grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
<ul className="m-0 grid list-none grid-cols-1 gap-x-4 gap-y-1.5 p-0 sm:grid-cols-2">
{facts.map(({ Icon, text }) => (
<li key={text} className="flex items-center gap-2 text-xs text-content-secondary">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-content-faint" aria-hidden="true" />
{text}
<li key={text} className="flex min-w-0 items-center gap-2 text-content-secondary" style={fs(12, 'body')}>
<span className="grid h-6 w-6 flex-none place-items-center rounded-[8px] bg-surface-tertiary text-content-muted">
<Icon size={13} strokeWidth={2} aria-hidden="true" />
</span>
<span className="min-w-0">{text}</span>
</li>
))}
</ul>
@@ -89,90 +98,61 @@ export default function TrekApiCard({ t }: TrekApiCardProps): React.ReactElement
<details className="group border-t border-edge-faint">
<summary
className="flex cursor-pointer list-none items-center gap-2 px-5 py-2.5
className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-2.5
hover:bg-surface-hover focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-accent
focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--text-primary)]
[&::-webkit-details-marker]:hidden"
>
<ChevronRight
className="h-4 w-4 flex-shrink-0 text-content-faint transition-transform group-open:rotate-90"
size={15}
strokeWidth={2}
className="flex-none text-content-faint transition-transform group-open:rotate-90"
aria-hidden="true"
/>
<span className="text-sm font-medium text-content-secondary">{t('admin.trekApi.more')}</span>
<span className="font-medium text-content" style={fs(13, 'body')}>{t('admin.trekApi.more')}</span>
</summary>
<div className="space-y-2.5 bg-surface-secondary/30 px-5 pb-5 pt-3">
<div className="divide-y divide-edge-faint border-t border-edge-faint bg-surface-secondary">
{/* The fields as chips rather than a paragraph. A list of what you
get is something you scan, not something you read, and a sentence
forces the reader to parse commas to answer "is the phone number
in there". */}
<div className="rounded-lg border border-edge-faint bg-surface-card/60 p-3">
<p className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
<Check className="h-3.5 w-3.5 text-success" aria-hidden="true" />
{t('admin.trekApi.included')}
</p>
<ul className="mt-2 flex flex-wrap gap-1.5">
{fields.map(field => (
<li
key={field}
className="rounded-full border border-edge-secondary bg-surface-card px-2 py-0.5 text-[11px] text-content-secondary"
>
{field}
</li>
))}
</ul>
<p className="mt-2 text-xs leading-relaxed text-content-faint">
{t('admin.trekApi.includedNote')}
</p>
</div>
<FactGroup
icon={<Check size={14} strokeWidth={2.4} className="text-success" aria-hidden="true" />}
title={t('admin.trekApi.included')}
note={t('admin.trekApi.includedNote')}
>
{fields.map(field => (
<li key={field} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{field}</li>
))}
</FactGroup>
{/* Named as plainly as what IS included. An admin who switches the
source expecting ratings and a photograph of every restaurant
should find that out here and not three weeks later. */}
<div className="rounded-lg border border-edge-faint bg-surface-card/60 p-3">
<p className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
<X className="h-3.5 w-3.5 text-content-faint" aria-hidden="true" />
{t('admin.trekApi.notIncluded')}
</p>
<ul className="mt-2 flex flex-wrap gap-1.5">
{[t('admin.trekApi.notRatings'), t('admin.trekApi.notPhotos')].map(item => (
<li
key={item}
className="rounded-full border border-dashed border-edge-secondary px-2 py-0.5 text-[11px] text-content-faint"
>
{item}
</li>
))}
</ul>
<p className="mt-2 text-xs leading-relaxed text-content-faint">
{t('admin.trekApi.notIncludedNote')}
</p>
</div>
<FactGroup
icon={<X size={14} strokeWidth={2.4} className="text-content-faint" aria-hidden="true" />}
title={t('admin.trekApi.notIncluded')}
note={t('admin.trekApi.notIncludedNote')}
>
{[t('admin.trekApi.notRatings'), t('admin.trekApi.notPhotos')].map(item => (
<li key={item} className={`${CHIP} border-dashed border-edge text-content-faint`}>{item}</li>
))}
</FactGroup>
{/* Attribution, and not in the small print: the licences require the
sources to be named, and naming them is also the answer to "where
does this actually come from". */}
<div className="rounded-lg border border-edge-faint bg-surface-card/60 p-3">
<p className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
<Library className="h-3.5 w-3.5 text-content-faint" aria-hidden="true" />
{t('admin.trekApi.sourcesLabel')}
</p>
<ul className="mt-2 flex flex-wrap gap-1.5">
{SOURCES.map(source => (
<li
key={source}
className="rounded-full border border-edge-secondary px-2 py-0.5 text-[11px] text-content-secondary"
>
{source}
</li>
))}
</ul>
<p className="mt-2 text-xs leading-relaxed text-content-faint">
{t('admin.trekApi.sourcesNote')}
</p>
</div>
<FactGroup
icon={<Library size={14} strokeWidth={2} className="text-content-faint" aria-hidden="true" />}
title={t('admin.trekApi.sourcesLabel')}
note={t('admin.trekApi.sourcesNote')}
>
{SOURCES.map(source => (
<li key={source} className={`${CHIP} border-edge-faint bg-surface-card text-content-secondary`}>{source}</li>
))}
</FactGroup>
</div>
</details>
</div>
</div>
)
}