Merge pull request #225 from andreibrebene/improvements/various-improvements

Improvements/various improvements
This commit is contained in:
Maurice
2026-03-31 21:40:26 +02:00
committed by GitHub
43 changed files with 1409 additions and 347 deletions
+174 -56
View File
@@ -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()
@@ -974,64 +974,182 @@ export default function AdminPage(): React.ReactElement {
</button>
</div>
</div>
{/* SMTP / Notifications */}
{/* Notifications — exclusive channel selector */}
<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.smtp.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.smtp.hint')}</p>
<h2 className="font-semibold text-slate-900">{t('admin.notifications.title')}</h2>
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
</div>
<div className="p-6 space-y-3">
{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' },
{ key: 'notification_webhook_url', label: 'Webhook URL (optional)', placeholder: 'https://discord.com/api/webhooks/...' },
{ key: 'app_url', label: 'App URL (for email links)', placeholder: 'https://trek.example.com' },
].map(field => (
<div key={field.key}>
<label className="block text-xs font-medium text-slate-500 mb-1">{field.label}</label>
<input
type={field.type || 'text'}
value={smtpValues[field.key] || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
onBlur={e => { if (e.target.value !== '') authApi.updateAppSettings({ [field.key]: e.target.value }).then(() => toast.success(t('common.saved'))).catch(() => {}) }}
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>
))}
{/* Skip TLS toggle */}
<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 onClick={async () => {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
await authApi.updateAppSettings({ smtp_skip_tls_verify: newVal }).catch(() => {})
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${smtpValues.smtp_skip_tls_verify === 'true' ? 'bg-slate-900' : 'bg-slate-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${smtpValues.smtp_skip_tls_verify === 'true' ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<div className="p-6 space-y-4">
{/* Channel selector */}
<div className="flex gap-2">
{(['none', 'email', 'webhook'] as const).map(ch => {
const active = (smtpValues.notification_channel || 'none') === ch
const labels: Record<string, string> = { none: t('admin.notifications.none'), email: t('admin.notifications.email'), webhook: t('admin.notifications.webhook') }
return (
<button
key={ch}
onClick={() => setSmtpValues(prev => ({ ...prev, notification_channel: ch }))}
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors border ${active ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50'}`}
>
{labels[ch]}
</button>
)
})}
</div>
{/* Notification event toggles — shown when any channel is active */}
{(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') },
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
{ key: 'notify_trip_reminder', label: t('settings.notifyTripReminder') },
{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') },
{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') },
{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') },
{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') },
].map(opt => {
const isOn = (smtpValues[opt.key] ?? 'true') !== 'false'
return (
<div key={opt.key} className="flex items-center justify-between py-1">
<span className="text-sm text-slate-700">{opt.label}</span>
<button
onClick={() => {
const newVal = isOn ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, [opt.key]: newVal }))
}}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isOn ? 'bg-slate-900' : 'bg-slate-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isOn ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)
})}
</div>
)
})()}
{/* Email (SMTP) settings — shown when email channel is active */}
{(smtpValues.notification_channel || 'none') === 'email' && (
<div className="space-y-3 pt-2 border-t border-slate-100">
<p className="text-xs text-slate-400">{t('admin.smtp.hint')}</p>
{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>
<input
type={field.type || 'text'}
value={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 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 items-center rounded-full transition-colors ${smtpValues.smtp_skip_tls_verify === 'true' ? 'bg-slate-900' : 'bg-slate-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${smtpValues.smtp_skip_tls_verify === 'true' ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
)}
{/* 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')}</p>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Webhook URL</label>
<input
type="text"
value={smtpValues.notification_webhook_url || ''}
onChange={e => setSmtpValues(prev => ({ ...prev, notification_webhook_url: e.target.value }))}
placeholder="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"
/>
<p className="text-[10px] text-slate-400 mt-1">TREK will POST JSON with event, title, body, and timestamp to this URL.</p>
</div>
</div>
)}
{/* Save + Test buttons */}
<div className="flex items-center gap-2 pt-2 border-t border-slate-100">
<button
onClick={async () => {
const notifKeys = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged']
const payload: Record<string, string> = {}
for (const k of notifKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] }
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"
>
<Save className="w-4 h-4" />
{t('common.save')}
</button>
{(smtpValues.notification_channel || 'none') === 'email' && (
<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]) 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')) }
}}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
{t('admin.smtp.testButton')}
</button>
)}
{(smtpValues.notification_channel || 'none') === 'webhook' && (
<button
onClick={async () => {
if (smtpValues.notification_webhook_url) {
await authApi.updateAppSettings({ notification_webhook_url: smtpValues.notification_webhook_url }).catch(() => {})
}
try {
const result = await notificationsApi.testWebhook()
if (result.success) toast.success(t('admin.notifications.testWebhookSuccess'))
else toast.error(result.error || t('admin.notifications.testWebhookFailed'))
} catch { toast.error(t('admin.notifications.testWebhookFailed')) }
}}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
{t('admin.notifications.testWebhook')}
</button>
)}
</div>
<button
onClick={async () => {
for (const k of ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from']) {
if (smtpValues[k]) await authApi.updateAppSettings({ [k]: smtpValues[k] }).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')) }
}}
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-medium hover:bg-slate-800 transition-colors"
>
{t('admin.smtp.testButton')}
</button>
</div>
</div>
</div>
@@ -1039,7 +1157,7 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'backup' && <BackupPanel />}
{activeTab === 'audit' && <AuditLogPanel />}
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
+20 -9
View File
@@ -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}
+83 -20
View File
@@ -9,6 +9,7 @@ import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe,
interface AppConfig {
has_users: boolean
allow_registration: boolean
setup_complete: boolean
demo_mode: boolean
oidc_configured: boolean
oidc_display_name?: string
@@ -28,7 +29,7 @@ export default function LoginPage(): React.ReactElement {
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const { login, register, demoLogin, completeMfaLogin } = useAuthStore()
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
@@ -110,19 +111,39 @@ export default function LoginPage(): React.ReactElement {
const [mfaStep, setMfaStep] = useState(false)
const [mfaToken, setMfaToken] = useState('')
const [mfaCode, setMfaCode] = useState('')
const [passwordChangeStep, setPasswordChangeStep] = useState(false)
const [savedLoginPassword, setSavedLoginPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
if (passwordChangeStep) {
if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return }
if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return }
if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return }
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
await loadUser({ silent: true })
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
return
}
if (mode === 'login' && mfaStep) {
if (!mfaCode.trim()) {
setError(t('login.mfaCodeRequired'))
setIsLoading(false)
return
}
await completeMfaLogin(mfaToken, mfaCode)
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
return
@@ -140,6 +161,12 @@ export default function LoginPage(): React.ReactElement {
setIsLoading(false)
return
}
if ('user' in result && result.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
setIsLoading(false)
return
}
}
setShowTakeoff(true)
setTimeout(() => navigate('/dashboard'), 2600)
@@ -149,7 +176,7 @@ export default function LoginPage(): React.ReactElement {
}
}
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode && (appConfig?.setup_complete !== false || !appConfig?.has_users)
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
@@ -516,18 +543,22 @@ export default function LoginPage(): React.ReactElement {
) : (
<>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
{mode === 'login' && mfaStep
? t('login.mfaTitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
: t('login.title')}
{passwordChangeStep
? t('login.setNewPassword')
: mode === 'login' && mfaStep
? t('login.mfaTitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount'))
: t('login.title')}
</h2>
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
{mode === 'login' && mfaStep
? t('login.mfaSubtitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
: t('login.subtitle')}
{passwordChangeStep
? t('login.setNewPasswordHint')
: mode === 'login' && mfaStep
? t('login.mfaSubtitle')
: mode === 'register'
? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint'))
: t('login.subtitle')}
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
@@ -537,7 +568,39 @@ export default function LoginPage(): React.ReactElement {
</div>
)}
{mode === 'login' && mfaStep && (
{passwordChangeStep && (
<>
<div style={{ padding: '10px 14px', background: '#fefce8', border: '1px solid #fde68a', borderRadius: 10, fontSize: 13, color: '#92400e' }}>
{t('settings.mustChangePassword')}
</div>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.newPassword')}</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="password" value={newPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value)} required
placeholder={t('settings.newPassword')} style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('settings.confirmPassword')}</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="password" value={confirmPassword} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)} required
placeholder={t('settings.confirmPassword')} style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
</>
)}
{mode === 'login' && mfaStep && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.mfaCodeLabel')}</label>
<div style={{ position: 'relative' }}>
@@ -567,7 +630,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Username (register only) */}
{mode === 'register' && (
{mode === 'register' && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
<div style={{ position: 'relative' }}>
@@ -583,7 +646,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Email */}
{!(mode === 'login' && mfaStep) && (
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
<div style={{ position: 'relative' }}>
@@ -599,7 +662,7 @@ export default function LoginPage(): React.ReactElement {
)}
{/* Password */}
{!(mode === 'login' && mfaStep) && (
{!(mode === 'login' && mfaStep) && !passwordChangeStep && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
<div style={{ position: 'relative' }}>
@@ -630,14 +693,14 @@ export default function LoginPage(): React.ReactElement {
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = '#111827'}
>
{isLoading
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
: <><Plane size={16} />{mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.creating') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signingIn'))}</>
: <><Plane size={16} />{passwordChangeStep ? t('settings.updatePassword') : mode === 'register' ? t('login.createAccount') : (mode === 'login' && mfaStep ? t('login.mfaVerify') : t('login.signIn'))}</>
}
</button>
</form>
{/* Toggle login/register */}
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && (
{showRegisterOption && appConfig?.has_users && !appConfig?.demo_mode && !passwordChangeStep && (
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError(''); setMfaStep(false); setMfaToken(''); setMfaCode('') }}
+42 -43
View File
@@ -7,7 +7,7 @@ import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check } from 'lucide-react'
import { authApi, adminApi, notificationsApi } from '../api/client'
import { authApi, adminApi } from '../api/client'
import apiClient from '../api/client'
import { useAddonStore } from '../store/addonStore'
import type { LucideIcon } from 'lucide-react'
@@ -56,56 +56,54 @@ function Section({ title, icon: Icon, children }: SectionProps): React.ReactElem
)
}
function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabled: boolean }) {
const [prefs, setPrefs] = useState<Record<string, number> | null>(null)
const [addons, setAddons] = useState<Record<string, boolean>>({})
useEffect(() => { notificationsApi.getPreferences().then(d => setPrefs(d.preferences)).catch(() => {}) }, [])
function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return (
<button onClick={onToggle}
style={{
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
<span style={{
position: 'absolute', top: 2, left: on ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: 'white',
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)
}
function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) {
const [notifChannel, setNotifChannel] = useState<string>('none')
useEffect(() => {
apiClient.get('/addons').then(r => {
const map: Record<string, boolean> = {}
for (const a of (r.data.addons || [])) map[a.id] = !!a.enabled
setAddons(map)
authApi.getAppConfig?.().then((cfg: any) => {
if (cfg?.notification_channel) setNotifChannel(cfg.notification_channel)
}).catch(() => {})
}, [])
const toggle = async (key: string) => {
if (!prefs) return
const newVal = prefs[key] ? 0 : 1
setPrefs(prev => prev ? { ...prev, [key]: newVal } : prev)
try { await notificationsApi.updatePreferences({ [key]: !!newVal }) } catch {}
if (notifChannel === 'none') {
return (
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
{t('settings.notificationsDisabled')}
</p>
)
}
if (!prefs) return <p style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</p>
const options = [
{ key: 'notify_trip_invite', label: t('settings.notifyTripInvite') },
{ key: 'notify_booking_change', label: t('settings.notifyBookingChange') },
...(addons.vacay ? [{ key: 'notify_vacay_invite', label: t('settings.notifyVacayInvite') }] : []),
...(memoriesEnabled ? [{ key: 'notify_photos_shared', label: t('settings.notifyPhotosShared') }] : []),
...(addons.collab ? [{ key: 'notify_collab_message', label: t('settings.notifyCollabMessage') }] : []),
...(addons.documents ? [{ key: 'notify_packing_tagged', label: t('settings.notifyPackingTagged') }] : []),
{ key: 'notify_webhook', label: t('settings.notifyWebhook') },
]
const channelLabel = notifChannel === 'email'
? (t('admin.notifications.email') || 'Email (SMTP)')
: (t('admin.notifications.webhook') || 'Webhook')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{options.map(opt => (
<div key={opt.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{opt.label}</span>
<button onClick={() => toggle(opt.key)}
style={{
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
background: prefs[opt.key] ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s',
}}>
<span style={{
position: 'absolute', top: 2, left: prefs[opt.key] ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: 'white',
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#22c55e', flexShrink: 0 }} />
<span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 500 }}>
{t('settings.notificationsActive')}: {channelLabel}
</span>
</div>
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: 1.5 }}>
{t('settings.notificationsManagedByAdmin')}
</p>
</div>
)
}
@@ -924,6 +922,7 @@ export default function SettingsPage(): React.ReactElement {
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
toast.success(t('settings.passwordChanged'))
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
await loadUser({ silent: true })
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')))
}