mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(settings,admin): every tab and popup in the planner's design
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user