feat: configurable trip reminders, admin full access, and enhanced audit logging
- Add configurable trip reminder days (1, 3, 9 or custom up to 30) settable by trip owner - Grant administrators full access to edit, archive, delete, view and list all trips - Show trip owner email in audit logs and docker logs when admin edits/deletes another user's trip - Show target user email in audit logs when admin edits or deletes a user account - Use email instead of username in all notifications (Discord/Slack/email) to avoid ambiguity - Grey out notification event toggles when no SMTP/webhook is configured - Grey out trip reminder selector when notifications are disabled - Skip local admin account creation when OIDC_ONLY=true with OIDC configured - Conditional scheduler logging: show disabled reason or active reminder count - Log per-owner reminder creation/update in docker logs - Demote 401/403 HTTP errors to DEBUG log level to reduce noise - Hide edit/archive/delete buttons for non-owner invited users on trip cards - Fix literal "0" rendering on trip cards from SQLite numeric is_owner field - Add missing translation keys across all 14 language files Made-with: Cursor
This commit is contained in:
@@ -122,7 +122,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const [updating, setUpdating] = useState<boolean>(false)
|
||||
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa } = useAuthStore()
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -999,9 +999,15 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Notification event toggles — shown when any channel is active */}
|
||||
{(smtpValues.notification_channel || 'none') !== 'none' && (
|
||||
<div className="space-y-2 pt-2 border-t border-slate-100">
|
||||
{(smtpValues.notification_channel || 'none') !== 'none' && (() => {
|
||||
const ch = smtpValues.notification_channel || 'none'
|
||||
const configValid = ch === 'email' ? !!(smtpValues.smtp_host?.trim()) : ch === 'webhook' ? !!(smtpValues.notification_webhook_url?.trim()) : false
|
||||
return (
|
||||
<div className={`space-y-2 pt-2 border-t border-slate-100 ${!configValid ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">{t('admin.notifications.events')}</p>
|
||||
{!configValid && (
|
||||
<p className="text-[10px] text-amber-600 mb-3">{t('admin.notifications.configureFirst')}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-400 mb-3">{t('admin.notifications.eventsHint')}</p>
|
||||
{[
|
||||
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
|
||||
@@ -1029,7 +1035,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Email (SMTP) settings — shown when email channel is active */}
|
||||
{(smtpValues.notification_channel || 'none') === 'email' && (
|
||||
@@ -1072,7 +1079,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
{/* Webhook settings — shown when webhook channel is active */}
|
||||
{(smtpValues.notification_channel || 'none') === 'webhook' && (
|
||||
<div className="space-y-3 pt-2 border-t border-slate-100">
|
||||
<p className="text-xs text-slate-400">{t('admin.webhook.hint') || 'Send notifications to an external webhook (Discord, Slack, etc.).'}</p>
|
||||
<p className="text-xs text-slate-400">{t('admin.webhook.hint')}</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
|
||||
<input
|
||||
@@ -1097,6 +1104,9 @@ export default function AdminPage(): React.ReactElement {
|
||||
try {
|
||||
await authApi.updateAppSettings(payload)
|
||||
toast.success(t('admin.notifications.saved'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} 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"
|
||||
|
||||
@@ -145,9 +145,10 @@ interface TripCardProps {
|
||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||
locale: string
|
||||
dark?: boolean
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
|
||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark, isAdmin }: TripCardProps): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
@@ -186,12 +187,14 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
||||
</div>
|
||||
|
||||
{/* Top-right actions */}
|
||||
{(!!trip.is_owner || isAdmin) && (
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
|
||||
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
|
||||
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom content */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
||||
@@ -228,7 +231,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
||||
}
|
||||
|
||||
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
@@ -305,19 +308,21 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
||||
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
||||
</div>
|
||||
|
||||
{(!!trip.is_owner || isAdmin) && (
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
|
||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
|
||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── List View Item ──────────────────────────────────────────────────────────
|
||||
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale, isAdmin }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
@@ -403,11 +408,13 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(!!trip.is_owner || isAdmin) && (
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
|
||||
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
|
||||
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -421,9 +428,10 @@ interface ArchivedRowProps {
|
||||
onClick: (trip: DashboardTrip) => void
|
||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||
locale: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
|
||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale, isAdmin }: ArchivedRowProps): React.ReactElement {
|
||||
return (
|
||||
<div onClick={() => onClick(trip)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
||||
@@ -449,6 +457,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(!!trip.is_owner || isAdmin) && (
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#6b7280' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
@@ -461,6 +470,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -539,7 +549,8 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode } = useAuthStore()
|
||||
const { demoMode, user } = useAuthStore()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const dm = settings.dark_mode
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
@@ -781,7 +792,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
{!isLoading && spotlight && viewMode === 'grid' && (
|
||||
<SpotlightCard
|
||||
trip={spotlight}
|
||||
t={t} locale={locale} dark={dark}
|
||||
t={t} locale={locale} dark={dark} isAdmin={isAdmin}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
@@ -797,7 +808,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
t={t} locale={locale} isAdmin={isAdmin}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
@@ -811,7 +822,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<TripListItem
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
t={t} locale={locale} isAdmin={isAdmin}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
@@ -841,7 +852,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<ArchivedRow
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
t={t} locale={locale} isAdmin={isAdmin}
|
||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||
onUnarchive={handleUnarchive}
|
||||
onDelete={handleDelete}
|
||||
|
||||
Reference in New Issue
Block a user