Merge branch 'dev' into test
This commit is contained in:
@@ -43,7 +43,8 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -27,7 +27,8 @@ apiClient.interceptors.response.use(
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||
window.location.href = '/login'
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -197,6 +198,8 @@ export const adminApi = {
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -326,9 +329,9 @@ export const shareApi = {
|
||||
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||
testWebhook: () => apiClient.post('/notifications/test-webhook').then(r => r.data),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
|
||||
@@ -2,7 +2,11 @@ import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Bell, Send, Zap, ArrowRight, CheckCircle, XCircle, Navigation, User } from 'lucide-react'
|
||||
import {
|
||||
Bell, Zap, ArrowRight, CheckCircle, Navigation, User,
|
||||
Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
|
||||
Download, MapPin,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Trip {
|
||||
id: number
|
||||
@@ -37,7 +41,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const send = async (label: string, payload: Record<string, unknown>) => {
|
||||
const fire = async (label: string, payload: Record<string, unknown>) => {
|
||||
setSending(label)
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
@@ -49,74 +53,69 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: 'Simple → Me',
|
||||
icon: Bell,
|
||||
color: '#6366f1',
|
||||
payload: {
|
||||
type: 'simple',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.title',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.text',
|
||||
text_params: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Boolean → Me',
|
||||
icon: CheckCircle,
|
||||
color: '#10b981',
|
||||
payload: {
|
||||
type: 'boolean',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.booleanTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.booleanText',
|
||||
text_params: {},
|
||||
positive_text_key: 'notifications.test.accept',
|
||||
negative_text_key: 'notifications.test.decline',
|
||||
positive_callback: { action: 'test_approve', payload: {} },
|
||||
negative_callback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Navigate → Me',
|
||||
icon: Navigation,
|
||||
color: '#f59e0b',
|
||||
payload: {
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: user?.id,
|
||||
title_key: 'notifications.test.navigateTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.navigateText',
|
||||
text_params: {},
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: '/dashboard',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Simple → Admins',
|
||||
icon: Zap,
|
||||
color: '#ef4444',
|
||||
payload: {
|
||||
type: 'simple',
|
||||
scope: 'admin',
|
||||
target: 0,
|
||||
title_key: 'notifications.test.adminTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.adminText',
|
||||
text_params: { actor: user?.username || 'Admin' },
|
||||
},
|
||||
},
|
||||
]
|
||||
const selectedTrip = trips.find(t => t.id === selectedTripId)
|
||||
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||
const username = user?.username || 'Admin'
|
||||
const tripTitle = selectedTrip?.title || 'Test Trip'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn = ({
|
||||
id, label, sub, icon: Icon, color, onClick,
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${color}20`, color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>{sub}</p>
|
||||
</div>
|
||||
{sending === id && (
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
const SectionTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>{children}</h3>
|
||||
)
|
||||
|
||||
const TripSelector = () => (
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{trips.map(trip => <option key={trip.id} value={trip.id}>{trip.title}</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
const UserSelector = () => (
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border text-sm mb-3"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{users.map(u => <option key={u.id} value={u.id}>{u.username} ({u.email})</option>)}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-0.5 rounded text-xs font-mono font-bold" style={{ background: '#fbbf24', color: '#000' }}>
|
||||
DEV ONLY
|
||||
</div>
|
||||
@@ -125,219 +124,162 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
Send test notifications to yourself, all admins, or trip members. These use test i18n keys.
|
||||
</p>
|
||||
|
||||
{/* Quick-fire buttons */}
|
||||
{/* ── Type Testing ─────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Quick Send</h3>
|
||||
<SectionTitle>Type Testing</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Test how each in-app notification type renders, sent to yourself.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{buttons.map(btn => {
|
||||
const Icon = btn.icon
|
||||
return (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={() => send(btn.label, btn.payload)}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${btn.color}20`, color: btn.color }}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{btn.label}</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-faint)' }}>
|
||||
{btn.payload.type} · {btn.payload.scope}
|
||||
</p>
|
||||
</div>
|
||||
{sending === btn.label && (
|
||||
<div className="ml-auto w-4 h-4 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
|
||||
onClick={() => fire('simple-me', {
|
||||
event: 'test_simple',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
|
||||
onClick={() => fire('boolean-me', {
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
|
||||
onClick={() => fire('navigate-me', {
|
||||
event: 'test_navigate',
|
||||
scope: 'user',
|
||||
targetId: user?.id,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
|
||||
onClick={() => fire('simple-admins', {
|
||||
event: 'test_simple',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: {},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip-scoped notifications */}
|
||||
{/* ── Trip-Scoped Events ───────────────────────────────────────────── */}
|
||||
{trips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>Trip-Scoped</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={selectedTripId ?? ''}
|
||||
onChange={e => setSelectedTripId(Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{trips.map(trip => (
|
||||
<option key={trip.id} value={trip.id}>{trip.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SectionTitle>Trip-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires each trip event to all members of the selected trip (excluding yourself).
|
||||
</p>
|
||||
<TripSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectedTripId && send('Simple → Trip', {
|
||||
type: 'simple',
|
||||
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
|
||||
onClick={() => selectedTripId && fire('booking_change', {
|
||||
event: 'booking_change',
|
||||
scope: 'trip',
|
||||
target: selectedTripId,
|
||||
title_key: 'notifications.test.tripTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.tripText',
|
||||
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedTripId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#8b5cf620', color: '#8b5cf6' }}>
|
||||
<Send className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → Trip Members</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · trip</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedTripId && send('Navigate → Trip', {
|
||||
type: 'navigate',
|
||||
/>
|
||||
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
|
||||
onClick={() => selectedTripId && fire('trip_reminder', {
|
||||
event: 'trip_reminder',
|
||||
scope: 'trip',
|
||||
target: selectedTripId,
|
||||
title_key: 'notifications.test.tripTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.tripText',
|
||||
text_params: { trip: trips.find(t => t.id === selectedTripId)?.title || 'Trip' },
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: `/trips/${selectedTripId}`,
|
||||
targetId: selectedTripId,
|
||||
params: { trip: tripTitle, tripId: String(selectedTripId) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedTripId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → Trip Members</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · trip</p>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
|
||||
onClick={() => selectedTripId && fire('photos_shared', {
|
||||
event: 'photos_shared',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
|
||||
onClick={() => selectedTripId && fire('collab_message', {
|
||||
event: 'collab_message',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
|
||||
onClick={() => selectedTripId && fire('packing_tagged', {
|
||||
event: 'packing_tagged',
|
||||
scope: 'trip',
|
||||
targetId: selectedTripId,
|
||||
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-scoped notifications */}
|
||||
{/* ── User-Scoped Events ───────────────────────────────────────────── */}
|
||||
{users.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--text-secondary)' }}>User-Scoped</h3>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<select
|
||||
value={selectedUserId ?? ''}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.username} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SectionTitle>User-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires each user event to the selected recipient.
|
||||
</p>
|
||||
<UserSelector />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Simple → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'simple',
|
||||
<Btn
|
||||
id={`trip_invite-${selectedUserId}`}
|
||||
label="trip_invite"
|
||||
sub="navigate · user"
|
||||
icon={UserPlus}
|
||||
color="#06b6d4"
|
||||
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, {
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.title',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.text',
|
||||
text_params: {},
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#06b6d420', color: '#06b6d4' }}>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Simple → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>simple · user</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Boolean → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'boolean',
|
||||
/>
|
||||
<Btn
|
||||
id={`vacay_invite-${selectedUserId}`}
|
||||
label="vacay_invite"
|
||||
sub="navigate · user"
|
||||
icon={MapPin}
|
||||
color="#f97316"
|
||||
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, {
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.booleanTitle',
|
||||
title_params: { actor: user?.username || 'Admin' },
|
||||
text_key: 'notifications.test.booleanText',
|
||||
text_params: {},
|
||||
positive_text_key: 'notifications.test.accept',
|
||||
negative_text_key: 'notifications.test.decline',
|
||||
positive_callback: { action: 'test_approve', payload: {} },
|
||||
negative_callback: { action: 'test_deny', payload: {} },
|
||||
targetId: selectedUserId,
|
||||
params: { actor: username, planId: '1' },
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#10b98120', color: '#10b981' }}>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Boolean → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>boolean · user</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedUserId && send(`Navigate → ${users.find(u => u.id === selectedUserId)?.username}`, {
|
||||
type: 'navigate',
|
||||
scope: 'user',
|
||||
target: selectedUserId,
|
||||
title_key: 'notifications.test.navigateTitle',
|
||||
title_params: {},
|
||||
text_key: 'notifications.test.navigateText',
|
||||
text_params: {},
|
||||
navigate_text_key: 'notifications.test.goThere',
|
||||
navigate_target: '/dashboard',
|
||||
})}
|
||||
disabled={sending !== null || !selectedUserId}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: '#f59e0b20', color: '#f59e0b' }}>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>Navigate → User</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>navigate · user</p>
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Admin-Scoped Events ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<SectionTitle>Admin-Scoped Events</SectionTitle>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
Fires to all admin users.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
|
||||
onClick={() => fire('version_available', {
|
||||
event: 'version_available',
|
||||
scope: 'admin',
|
||||
targetId: 0,
|
||||
params: { version: '9.9.9-test' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
30
client/src/components/Settings/AboutTab.tsx
Normal file
30
client/src/components/Settings/AboutTab.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from './Section'
|
||||
|
||||
interface Props {
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '6px 14px' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)' }}>TREK</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
598
client/src/components/Settings/AccountTab.tsx
Normal file
598
client/src/components/Settings/AccountTab.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { User, Save, Lock, KeyRound, AlertTriangle, Shield, Camera, Trash2, Copy, Download, Printer } from 'lucide-react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi, adminApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { UserWithOidc } from '../../types'
|
||||
import Section from './Section'
|
||||
|
||||
const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending'
|
||||
|
||||
export default function AccountTab(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
|
||||
// Profile
|
||||
const [username, setUsername] = useState<string>(user?.username || '')
|
||||
const [email, setEmail] = useState<string>(user?.email || '')
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(user?.username || '')
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
// Password
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [oidcOnlyMode, setOidcOnlyMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then(config => {
|
||||
if (config?.oidc_only_mode) setOidcOnlyMode(true)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// MFA
|
||||
const [mfaQr, setMfaQr] = useState<string | null>(null)
|
||||
const [mfaSecret, setMfaSecret] = useState<string | null>(null)
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('')
|
||||
const [mfaDisablePwd, setMfaDisablePwd] = useState('')
|
||||
const [mfaDisableCode, setMfaDisableCode] = useState('')
|
||||
const [mfaLoading, setMfaLoading] = useState(false)
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
|
||||
|
||||
const mfaRequiredByPolicy =
|
||||
!demoMode &&
|
||||
!user?.mfa_enabled &&
|
||||
(searchParams.get('mfa') === 'required' || appRequireMfa)
|
||||
|
||||
const backupCodesText = backupCodes?.join('\n') || ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.mfa_enabled || backupCodes) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MFA_BACKUP_SESSION_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => typeof x === 'string')) {
|
||||
setBackupCodes(parsed)
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
}
|
||||
}, [user?.mfa_enabled, backupCodes])
|
||||
|
||||
const dismissBackupCodes = () => {
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
}
|
||||
|
||||
const copyBackupCodes = async () => {
|
||||
if (!backupCodesText) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesText)
|
||||
toast.success(t('settings.mfa.backupCopied'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const blob = new Blob([backupCodesText + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'trek-mfa-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const printBackupCodes = () => {
|
||||
if (!backupCodesText) return
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${new Date().toLocaleString()}</p><pre>${backupCodesText}</pre></body></html>`
|
||||
const w = window.open('', '_blank', 'width=900,height=700')
|
||||
if (!w) return
|
||||
w.document.open()
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.focus()
|
||||
w.print()
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
await uploadAvatar(file)
|
||||
toast.success(t('settings.avatarUploaded'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
} catch {
|
||||
toast.error(t('settings.avatarError'))
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
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>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
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>
|
||||
|
||||
{/* Change Password */}
|
||||
{!oidcOnlyMode && (
|
||||
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={e => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder={t('settings.newPassword')}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('settings.confirmPassword')}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
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')))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MFA */}
|
||||
<div style={{ paddingTop: 16, marginTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<KeyRound className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h3 className="font-semibold text-base m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.title')}</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{mfaRequiredByPolicy && (
|
||||
<div className="flex gap-3 p-3 rounded-lg border text-sm"
|
||||
style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-amber-600" />
|
||||
<p className="m-0 leading-relaxed">{t('settings.mfa.requiredByPolicy')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm m-0" style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.mfa.description')}</p>
|
||||
{demoMode ? (
|
||||
<p className="text-sm text-amber-700 m-0">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium m-0" style={{ color: 'var(--text-secondary)' }}>
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
|
||||
{!user?.mfa_enabled && !mfaQr && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const data = await authApi.mfaSetup() as { qr_data_url: string; secret: string }
|
||||
setMfaQr(data.qr_data_url)
|
||||
setMfaSecret(data.secret)
|
||||
setMfaSetupCode('')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{mfaLoading ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> : <KeyRound size={14} />}
|
||||
{t('settings.mfa.setup')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!user?.mfa_enabled && mfaQr && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.scanQr')}</p>
|
||||
<img src={mfaQr} alt="" className="rounded-lg border mx-auto block" style={{ maxWidth: 200, borderColor: 'var(--border-primary)' }} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.secretLabel')}</label>
|
||||
<code className="block text-xs p-2 rounded break-all" style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}>{mfaSecret}</code>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaSetupCode}
|
||||
onChange={e => setMfaSetupCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || mfaSetupCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
const resp = await authApi.mfaEnable({ code: mfaSetupCode }) as { backup_codes?: string[] }
|
||||
toast.success(t('settings.mfa.toastEnabled'))
|
||||
setMfaQr(null)
|
||||
setMfaSecret(null)
|
||||
setMfaSetupCode('')
|
||||
const codes = resp.backup_codes || null
|
||||
if (codes?.length) {
|
||||
try { sessionStorage.setItem(MFA_BACKUP_SESSION_KEY, JSON.stringify(codes)) } catch { /* ignore */ }
|
||||
}
|
||||
setBackupCodes(codes)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.enable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMfaQr(null); setMfaSecret(null); setMfaSetupCode('') }}
|
||||
className="px-4 py-2 rounded-lg text-sm border"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{t('settings.mfa.cancelSetup')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.mfa_enabled && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mfa.disableTitle')}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.disableHint')}</p>
|
||||
<input
|
||||
type="password"
|
||||
value={mfaDisablePwd}
|
||||
onChange={e => setMfaDisablePwd(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={mfaLoading || !mfaDisablePwd || mfaDisableCode.length < 6}
|
||||
onClick={async () => {
|
||||
setMfaLoading(true)
|
||||
try {
|
||||
await authApi.mfaDisable({ password: mfaDisablePwd, code: mfaDisableCode })
|
||||
toast.success(t('settings.mfa.toastDisabled'))
|
||||
setMfaDisablePwd('')
|
||||
setMfaDisableCode('')
|
||||
sessionStorage.removeItem(MFA_BACKUP_SESSION_KEY)
|
||||
setBackupCodes(null)
|
||||
await loadUser({ silent: true })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setMfaLoading(false)
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-red-600 border border-red-200 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="space-y-3 p-3 rounded-lg border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-hover)' }}>
|
||||
<p className="text-sm font-semibold m-0" style={{ color: 'var(--text-primary)' }}>{t('settings.mfa.backupTitle')}</p>
|
||||
<p className="text-xs m-0" style={{ color: 'var(--text-muted)' }}>{t('settings.mfa.backupDescription')}</p>
|
||||
<pre className="text-xs m-0 p-2 rounded border overflow-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', maxHeight: 220 }}>{backupCodesText}</pre>
|
||||
<p className="text-xs m-0" style={{ color: '#b45309' }}>{t('settings.mfa.backupWarning')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={copyBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Copy size={13} /> {t('settings.mfa.backupCopy')}
|
||||
</button>
|
||||
<button type="button" onClick={downloadBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Download size={13} /> {t('settings.mfa.backupDownload')}
|
||||
</button>
|
||||
<button type="button" onClick={printBackupCodes} className="px-3 py-2 rounded-lg text-xs border flex items-center gap-1.5" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
<Printer size={13} /> {t('settings.mfa.backupPrint')}
|
||||
</button>
|
||||
<button type="button" onClick={dismissBackupCodes} className="px-3 py-2 rounded-lg text-xs border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{user?.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24, fontWeight: 700,
|
||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{user?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" onChange={handleAvatarUpload} style={{ display: 'none' }} />
|
||||
<button
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
style={{
|
||||
position: 'absolute', bottom: -3, right: -3,
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'var(--text-primary)', color: 'var(--bg-card)',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
{user?.avatar_url && (
|
||||
<button
|
||||
onClick={handleAvatarRemove}
|
||||
style={{
|
||||
position: 'absolute', top: -2, right: -2,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: '#ef4444', color: 'white',
|
||||
border: '2px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
|
||||
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
|
||||
</span>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
|
||||
background: '#dbeafe', color: '#1d4ed8', marginLeft: 6,
|
||||
}}>
|
||||
SSO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
|
||||
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
|
||||
<span className="sm:hidden">{t('common.save')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
|
||||
style={{ border: '1px solid #fecaca' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
|
||||
<span className="sm:hidden">{t('common.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<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: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} style={{ color: '#d97706' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteBlockedMessage')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.ok') || 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirm */}
|
||||
{showDeleteConfirm === true && (
|
||||
<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: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteAccountWarning')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||
border: 'none', background: '#ef4444', color: 'white',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('settings.deleteAccountConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
206
client/src/components/Settings/DisplaySettingsTab.tsx
Normal file
206
client/src/components/Settings/DisplaySettingsTab.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import Section from './Section'
|
||||
|
||||
export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
|
||||
useEffect(() => {
|
||||
setTempUnit(settings.temperature_unit || 'celsius')
|
||||
}, [settings.temperature_unit])
|
||||
|
||||
return (
|
||||
<Section title={t('settings.display')} icon={Palette}>
|
||||
{/* Color Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
|
||||
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ value: 'light', label: t('settings.light'), icon: Sun },
|
||||
{ value: 'dark', label: t('settings.dark'), icon: Moon },
|
||||
{ value: 'auto', label: t('settings.auto'), icon: Monitor },
|
||||
].map(opt => {
|
||||
const current = settings.dark_mode
|
||||
const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '10px 14px', borderRadius: 10, cursor: 'pointer', flex: '1 1 0', justifyContent: 'center', minWidth: 0,
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SUPPORTED_LANGUAGES.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.temperature')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: 'celsius', label: '°C Celsius' },
|
||||
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Format */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: '24h', label: '24h (14:30)' },
|
||||
{ value: '12h', label: '12h (2:30 PM)' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('blur_booking_codes', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (!!settings.blur_booking_codes) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (!!settings.blur_booking_codes) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
343
client/src/components/Settings/IntegrationsTab.tsx
Normal file
343
client/src/components/Settings/IntegrationsTab.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Camera, Terminal, Save, Check, Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi } from '../../api/client'
|
||||
import apiClient from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Section from './Section'
|
||||
|
||||
interface McpToken {
|
||||
id: number
|
||||
name: string
|
||||
token_prefix: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { isEnabled: addonEnabled } = useAddonStore()
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
|
||||
// Immich state
|
||||
const [immichUrl, setImmichUrl] = useState('')
|
||||
const [immichApiKey, setImmichApiKey] = useState('')
|
||||
const [immichConnected, setImmichConnected] = useState(false)
|
||||
const [immichTesting, setImmichTesting] = useState(false)
|
||||
const [immichSaving, setImmichSaving] = useState(false)
|
||||
const [immichTestPassed, setImmichTestPassed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (memoriesEnabled) {
|
||||
apiClient.get('/integrations/immich/settings').then(r => {
|
||||
setImmichUrl(r.data.immich_url || '')
|
||||
setImmichConnected(r.data.connected)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [memoriesEnabled])
|
||||
|
||||
const handleSaveImmich = async () => {
|
||||
setImmichSaving(true)
|
||||
try {
|
||||
const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||
if (saveRes.data.warning) toast.warning(saveRes.data.warning)
|
||||
toast.success(t('memories.saved'))
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
setImmichConnected(res.data.connected)
|
||||
setImmichTestPassed(false)
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
setImmichSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestImmich = async () => {
|
||||
setImmichTesting(true)
|
||||
try {
|
||||
const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey })
|
||||
if (res.data.connected) {
|
||||
if (res.data.canonicalUrl) {
|
||||
setImmichUrl(res.data.canonicalUrl)
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''} (URL updated to ${res.data.canonicalUrl})`)
|
||||
} else {
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||
}
|
||||
setImmichTestPassed(true)
|
||||
} else {
|
||||
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||
setImmichTestPassed(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
setImmichTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MCP state
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
const [mcpNewName, setMcpNewName] = useState('')
|
||||
const [mcpCreatedToken, setMcpCreatedToken] = useState<string | null>(null)
|
||||
const [mcpCreating, setMcpCreating] = useState(false)
|
||||
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
|
||||
const mcpEndpoint = `${window.location.origin}/mcp`
|
||||
const mcpJsonConfig = `{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${mcpEndpoint}",
|
||||
"--header",
|
||||
"Authorization: Bearer <your_token>"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
useEffect(() => {
|
||||
if (mcpEnabled) {
|
||||
authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {})
|
||||
}
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateMcpToken = async () => {
|
||||
if (!mcpNewName.trim()) return
|
||||
setMcpCreating(true)
|
||||
try {
|
||||
const d = await authApi.mcpTokens.create(mcpNewName.trim())
|
||||
setMcpCreatedToken(d.token.raw_token)
|
||||
setMcpNewName('')
|
||||
setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev])
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.createError'))
|
||||
} finally {
|
||||
setMcpCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMcpToken = async (id: number) => {
|
||||
try {
|
||||
await authApi.mcpTokens.delete(id)
|
||||
setMcpTokens(prev => prev.filter(tk => tk.id !== id))
|
||||
setMcpDeleteId(null)
|
||||
toast.success(t('settings.mcp.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{memoriesEnabled && (
|
||||
<Section title="Immich" icon={Camera}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||
<input type="url" value={immichUrl} onChange={e => { setImmichUrl(e.target.value); setImmichTestPassed(false) }}
|
||||
placeholder="https://immich.example.com"
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||
<input type="password" value={immichApiKey} onChange={e => { setImmichApiKey(e.target.value); setImmichTestPassed(false) }}
|
||||
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleSaveImmich} disabled={immichSaving || !immichTestPassed}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!immichTestPassed ? t('memories.testFirst') : ''}>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50">
|
||||
{immichTesting
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{immichConnected && (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{mcpEnabled && (
|
||||
<Section title={t('settings.mcp.title')} icon={Terminal}>
|
||||
{/* Endpoint URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.endpoint')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 rounded-lg text-sm font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpEndpoint}
|
||||
</code>
|
||||
<button onClick={() => handleCopy(mcpEndpoint, 'endpoint')}
|
||||
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'endpoint' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON config box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
|
||||
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpJsonConfig}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label>
|
||||
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mcpTokens.length === 0 ? (
|
||||
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
|
||||
{t('settings.mcp.noTokens')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{mcpTokens.map((token, i) => (
|
||||
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.token_prefix}...
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setMcpDeleteId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Create MCP Token modal */}
|
||||
{mcpModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget && !mcpCreatedToken) setMcpModalOpen(false) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
{!mcpCreatedToken ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createTitle')}</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.tokenName')}</label>
|
||||
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<button onClick={() => setMcpModalOpen(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.modal.createdTitle')}</h3>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
|
||||
<span className="text-amber-500 mt-0.5">⚠</span>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.modal.createdWarning')}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="p-3 pr-10 rounded-lg text-xs font-mono break-all border whitespace-pre-wrap" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
|
||||
{mcpCreatedToken}
|
||||
</pre>
|
||||
<button onClick={() => handleCopy(mcpCreatedToken, 'new-token')}
|
||||
className="absolute top-2 right-2 p-1.5 rounded transition-colors hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||
style={{ color: 'var(--text-secondary)' }} title={t('settings.mcp.copy')}>
|
||||
{copiedKey === 'new-token' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete MCP Token confirm */}
|
||||
{mcpDeleteId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={e => { if (e.target === e.currentTarget) setMcpDeleteId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.mcp.deleteTokenTitle')}</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.deleteTokenMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setMcpDeleteId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteMcpToken(mcpDeleteId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('settings.mcp.deleteTokenTitle')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
162
client/src/components/Settings/MapSettingsTab.tsx
Normal file
162
client/src/components/Settings/MapSettingsTab.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Map, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import Section from './Section'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
export default function MapSettingsTab(): React.ReactElement {
|
||||
const { settings, updateSettings } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
setDefaultLng(settings.default_lng || 2.3522)
|
||||
setDefaultZoom(settings.default_zoom || 10)
|
||||
}, [settings])
|
||||
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
setDefaultLng(mapInfo.latlng.lng)
|
||||
}, [])
|
||||
|
||||
const mapPlaces = useMemo((): Place[] => [{
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
name: 'Default map center',
|
||||
description: '',
|
||||
lat: defaultLat as number,
|
||||
lng: defaultLng as number,
|
||||
address: '',
|
||||
category_id: 0,
|
||||
icon: null,
|
||||
price: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
created_at: Date(),
|
||||
}], [defaultLat, defaultLng])
|
||||
|
||||
const saveMapSettings = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateSettings({
|
||||
map_tile_url: mapTileUrl,
|
||||
default_lat: parseFloat(String(defaultLat)),
|
||||
default_lng: parseFloat(String(defaultLng)),
|
||||
default_zoom: parseInt(String(defaultZoom)),
|
||||
})
|
||||
toast.success(t('settings.toast.mapSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
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-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLat}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
|
||||
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>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.longitude')}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLng}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
|
||||
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>
|
||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: handleMapClick,
|
||||
onMapContextMenu: null,
|
||||
center: [settings.default_lat, settings.default_lng],
|
||||
zoom: defaultZoom,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveMapSettings}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
{saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('settings.saveMap')}
|
||||
</button>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
196
client/src/components/Settings/NotificationsTab.tsx
Normal file
196
client/src/components/Settings/NotificationsTab.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { notificationsApi, settingsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import Section from './Section'
|
||||
|
||||
interface PreferencesMatrix {
|
||||
preferences: Record<string, Record<string, boolean>>
|
||||
available_channels: { email: boolean; webhook: boolean; inapp: boolean }
|
||||
event_types: string[]
|
||||
implemented_combos: Record<string, string[]>
|
||||
}
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
}
|
||||
|
||||
const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
trip_invite: 'settings.notifyTripInvite',
|
||||
booking_change: 'settings.notifyBookingChange',
|
||||
trip_reminder: 'settings.notifyTripReminder',
|
||||
vacay_invite: 'settings.notifyVacayInvite',
|
||||
photos_shared: 'settings.notifyPhotosShared',
|
||||
collab_message: 'settings.notifyCollabMessage',
|
||||
packing_tagged: 'settings.notifyPackingTagged',
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
}
|
||||
|
||||
export default function NotificationsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [webhookIsSet, setWebhookIsSet] = useState(false)
|
||||
const [webhookSaving, setWebhookSaving] = useState(false)
|
||||
const [webhookTesting, setWebhookTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
notificationsApi.getPreferences().then((data: PreferencesMatrix) => setMatrix(data)).catch(() => {})
|
||||
settingsApi.get().then((data: { settings: Record<string, unknown> }) => {
|
||||
const val = (data.settings?.webhook_url as string) || ''
|
||||
if (val === '••••••••') {
|
||||
setWebhookIsSet(true)
|
||||
setWebhookUrl('')
|
||||
} else {
|
||||
setWebhookUrl(val)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const visibleChannels = matrix
|
||||
? (['email', 'webhook', 'inapp'] as const).filter(ch => {
|
||||
if (!matrix.available_channels[ch]) return false
|
||||
return matrix.event_types.some(evt => matrix.implemented_combos[evt]?.includes(ch))
|
||||
})
|
||||
: []
|
||||
|
||||
const toggle = async (eventType: string, channel: string) => {
|
||||
if (!matrix) return
|
||||
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||
const updated = {
|
||||
...matrix.preferences,
|
||||
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
|
||||
}
|
||||
setMatrix(m => m ? { ...m, preferences: updated } : m)
|
||||
setSaving(true)
|
||||
try {
|
||||
await notificationsApi.updatePreferences(updated)
|
||||
} catch {
|
||||
setMatrix(m => m ? { ...m, preferences: matrix.preferences } : m)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveWebhookUrl = async () => {
|
||||
setWebhookSaving(true)
|
||||
try {
|
||||
await settingsApi.set('webhook_url', webhookUrl)
|
||||
if (webhookUrl) setWebhookIsSet(true)
|
||||
else setWebhookIsSet(false)
|
||||
toast.success(t('settings.webhookUrl.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setWebhookSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testWebhookUrl = async () => {
|
||||
if (!webhookUrl && !webhookIsSet) return
|
||||
setWebhookTesting(true)
|
||||
try {
|
||||
const result = await notificationsApi.testWebhook(webhookUrl || undefined)
|
||||
if (result.success) toast.success(t('settings.webhookUrl.testSuccess'))
|
||||
else toast.error(result.error || t('settings.webhookUrl.testFailed'))
|
||||
} catch {
|
||||
toast.error(t('settings.webhookUrl.testFailed'))
|
||||
} finally {
|
||||
setWebhookTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading…</p>
|
||||
|
||||
if (visibleChannels.length === 0) {
|
||||
return (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{t('settings.notificationPreferences.noChannels')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||
{matrix.available_channels.webhook && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
|
||||
{t('settings.webhookUrl.label')}
|
||||
</label>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('settings.webhookUrl.hint')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={webhookUrl}
|
||||
onChange={e => setWebhookUrl(e.target.value)}
|
||||
placeholder={webhookIsSet ? '••••••••' : t('settings.webhookUrl.placeholder')}
|
||||
style={{ flex: 1, fontSize: 13, padding: '6px 10px', border: '1px solid var(--border-primary)', borderRadius: 6, background: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={saveWebhookUrl}
|
||||
disabled={webhookSaving}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'var(--text-primary)', color: 'var(--bg-primary)', border: 'none', borderRadius: 6, cursor: webhookSaving ? 'not-allowed' : 'pointer', opacity: webhookSaving ? 0.6 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={testWebhookUrl}
|
||||
disabled={(!webhookUrl && !webhookIsSet) || webhookTesting}
|
||||
style={{ fontSize: 12, padding: '6px 12px', background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, cursor: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 'not-allowed' : 'pointer', opacity: ((!webhookUrl && !webhookIsSet) || webhookTesting) ? 0.5 : 1 }}
|
||||
>
|
||||
{t('settings.webhookUrl.test')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t(CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map(eventType => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
const relevantChannels = visibleChannels.filter(ch => implementedForEvent.includes(ch))
|
||||
if (relevantChannels.length === 0) return null
|
||||
return (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '64px').join(' ')}`, gap: 4, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t(EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch)) {
|
||||
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||
return (
|
||||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<ToggleSwitch on={isOn} onToggle={() => toggle(eventType, ch)} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.notifications')} icon={Lock}>
|
||||
{renderContent()}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
22
client/src/components/Settings/Section.tsx
Normal file
22
client/src/components/Settings/Section.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', marginBottom: 24 }}>
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
client/src/components/Settings/ToggleSwitch.tsx
Normal file
18
client/src/components/Settings/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
export default 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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Settings
|
||||
'settings.title': 'Settings',
|
||||
'settings.subtitle': 'Configure your personal settings',
|
||||
'settings.tabs.display': 'Display',
|
||||
'settings.tabs.map': 'Map',
|
||||
'settings.tabs.notifications': 'Notifications',
|
||||
'settings.tabs.integrations': 'Integrations',
|
||||
'settings.tabs.account': 'Account',
|
||||
'settings.tabs.about': 'About',
|
||||
'settings.map': 'Map',
|
||||
'settings.mapTemplate': 'Map Template',
|
||||
'settings.mapTemplatePlaceholder.select': 'Select template...',
|
||||
@@ -163,23 +169,44 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Chat messages (Collab)',
|
||||
'settings.notifyPackingTagged': 'Packing list: assignments',
|
||||
'settings.notifyWebhook': 'Webhook notifications',
|
||||
'settings.notifyVersionAvailable': 'New version available',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.noChannels': 'No notification channels are configured. Ask an admin to set up email or webhook notifications.',
|
||||
'settings.webhookUrl.label': 'Webhook URL',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Enter your Discord, Slack, or custom webhook URL to receive notifications.',
|
||||
'settings.webhookUrl.save': 'Save',
|
||||
'settings.webhookUrl.saved': 'Webhook URL saved',
|
||||
'settings.webhookUrl.test': 'Test',
|
||||
'settings.webhookUrl.testSuccess': 'Test webhook sent successfully',
|
||||
'settings.webhookUrl.testFailed': 'Test webhook failed',
|
||||
'admin.notifications.title': 'Notifications',
|
||||
'admin.notifications.hint': 'Choose one notification channel. Only one can be active at a time.',
|
||||
'admin.notifications.none': 'Disabled',
|
||||
'admin.notifications.email': 'Email (SMTP)',
|
||||
'admin.notifications.webhook': 'Webhook',
|
||||
'admin.notifications.events': 'Notification Events',
|
||||
'admin.notifications.eventsHint': 'Choose which events trigger notifications for all users.',
|
||||
'admin.notifications.configureFirst': 'Configure the SMTP or webhook settings below first, then enable events.',
|
||||
'admin.notifications.save': 'Save notification settings',
|
||||
'admin.notifications.saved': 'Notification settings saved',
|
||||
'admin.notifications.testWebhook': 'Send test webhook',
|
||||
'admin.notifications.testWebhookSuccess': 'Test webhook sent successfully',
|
||||
'admin.notifications.testWebhookFailed': 'Test webhook failed',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'In-app notifications are always active and cannot be disabled globally.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Admin Webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'This webhook is used exclusively for admin notifications (e.g. version alerts). It is separate from per-user webhooks and always fires when set.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'Admin webhook URL saved',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Test webhook sent successfully',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Test webhook failed',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Admin webhook always fires when a URL is configured',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
||||
'admin.smtp.title': 'Email & Notifications',
|
||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||
'admin.smtp.testButton': 'Send test email',
|
||||
'admin.webhook.hint': 'Send notifications to an external webhook (Discord, Slack, etc.).',
|
||||
'admin.webhook.hint': 'Allow users to configure their own webhook URLs for notifications (Discord, Slack, etc.).',
|
||||
'admin.smtp.testSuccess': 'Test email sent successfully',
|
||||
'admin.smtp.testFailed': 'Test email failed',
|
||||
'settings.notificationsDisabled': 'Notifications are not configured. Ask an admin to enable email or webhook notifications.',
|
||||
@@ -383,6 +410,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'Users',
|
||||
'admin.tabs.categories': 'Categories',
|
||||
'admin.tabs.backup': 'Backup',
|
||||
'admin.tabs.notifications': 'Notifications',
|
||||
'admin.tabs.notificationChannels': 'Notification Channels',
|
||||
'admin.tabs.adminNotifications': 'Admin Notifications',
|
||||
'admin.tabs.audit': 'Audit log',
|
||||
'admin.stats.users': 'Users',
|
||||
'admin.stats.trips': 'Trips',
|
||||
@@ -685,8 +715,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Remove',
|
||||
'atlas.confirmMark': 'Mark this country as visited?',
|
||||
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
||||
'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?',
|
||||
'atlas.markVisited': 'Mark as visited',
|
||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||
'atlas.markRegionVisitedHint': 'Add this region to your visited list',
|
||||
'atlas.addToBucket': 'Add to bucket list',
|
||||
'atlas.addPoi': 'Add place',
|
||||
'atlas.searchCountry': 'Search a country...',
|
||||
@@ -1565,6 +1597,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.system': 'System',
|
||||
|
||||
// Notification test keys (dev only)
|
||||
'notifications.versionAvailable.title': 'Update Available',
|
||||
'notifications.versionAvailable.text': 'TREK {version} is now available.',
|
||||
'notifications.versionAvailable.button': 'View Details',
|
||||
'notifications.test.title': 'Test notification from {actor}',
|
||||
'notifications.test.text': 'This is a simple test notification.',
|
||||
'notifications.test.booleanTitle': '{actor} asks for your approval',
|
||||
@@ -1612,6 +1647,43 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.detail.priority': 'Priority',
|
||||
'todo.detail.noPriority': 'None',
|
||||
'todo.detail.create': 'Create task',
|
||||
|
||||
// Notifications — dev test events
|
||||
'notif.test.title': '[Test] Notification',
|
||||
'notif.test.simple.text': 'This is a simple test notification.',
|
||||
'notif.test.boolean.text': 'Do you accept this test notification?',
|
||||
'notif.test.navigate.text': 'Click below to navigate to the dashboard.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Trip Invitation',
|
||||
'notif.trip_invite.text': '{actor} invited you to {trip}',
|
||||
'notif.booking_change.title': 'Booking Updated',
|
||||
'notif.booking_change.text': '{actor} updated a booking in {trip}',
|
||||
'notif.trip_reminder.title': 'Trip Reminder',
|
||||
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
|
||||
'notif.vacay_invite.title': 'Vacay Fusion Invite',
|
||||
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
|
||||
'notif.photos_shared.title': 'Photos Shared',
|
||||
'notif.photos_shared.text': '{actor} shared {count} photo(s) in {trip}',
|
||||
'notif.collab_message.title': 'New Message',
|
||||
'notif.collab_message.text': '{actor} sent a message in {trip}',
|
||||
'notif.packing_tagged.title': 'Packing Assignment',
|
||||
'notif.packing_tagged.text': '{actor} assigned you to {category} in {trip}',
|
||||
'notif.version_available.title': 'New Version Available',
|
||||
'notif.version_available.text': 'TREK {version} is now available',
|
||||
'notif.action.view_trip': 'View Trip',
|
||||
'notif.action.view_collab': 'View Messages',
|
||||
'notif.action.view_packing': 'View Packing',
|
||||
'notif.action.view_photos': 'View Photos',
|
||||
'notif.action.view_vacay': 'View Vacay',
|
||||
'notif.action.view_admin': 'Go to Admin',
|
||||
'notif.action.view': 'View',
|
||||
'notif.action.accept': 'Accept',
|
||||
'notif.action.decline': 'Decline',
|
||||
'notif.generic.title': 'Notification',
|
||||
'notif.generic.text': 'You have a new notification',
|
||||
'notif.dev.unknown_event.title': '[DEV] Unknown Event',
|
||||
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Settings
|
||||
'settings.title': 'Ustawienia',
|
||||
'settings.subtitle': 'Skonfiguruj swoje ustawienia',
|
||||
'settings.tabs.display': 'Wygląd',
|
||||
'settings.tabs.map': 'Mapa',
|
||||
'settings.tabs.notifications': 'Powiadomienia',
|
||||
'settings.tabs.integrations': 'Integracje',
|
||||
'settings.tabs.account': 'Konto',
|
||||
'settings.tabs.about': 'O aplikacji',
|
||||
'settings.map': 'Mapa',
|
||||
'settings.mapTemplate': 'Szablon mapy',
|
||||
'settings.mapTemplatePlaceholder.select': 'Wybierz szablon...',
|
||||
@@ -149,6 +155,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
|
||||
'settings.notifyPackingTagged': 'Lista pakowania: przypisania',
|
||||
'settings.notifyWebhook': 'Powiadomienia Webhook',
|
||||
'settings.notifyVersionAvailable': 'Nowa wersja dostępna',
|
||||
'admin.smtp.title': 'E-maile i powiadomienia',
|
||||
'admin.smtp.hint': 'Konfiguracja SMTP dla powiadomień e-mail. Opcjonalnie: URL Webhooka dla Discorda, Slacka, itp.',
|
||||
'admin.smtp.testButton': 'Wyślij testowego e-maila',
|
||||
@@ -349,6 +356,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.users': 'Użytkownicy',
|
||||
'admin.tabs.categories': 'Kategorie',
|
||||
'admin.tabs.backup': 'Backupy',
|
||||
'admin.tabs.notifications': 'Powiadomienia',
|
||||
'admin.tabs.notificationChannels': 'Kanały powiadomień',
|
||||
'admin.tabs.adminNotifications': 'Powiadomienia admina',
|
||||
'admin.tabs.audit': 'Aktywność',
|
||||
'admin.stats.users': 'Użytkownicy',
|
||||
'admin.stats.trips': 'Podróże',
|
||||
@@ -455,8 +465,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.tabs.addons': 'Dodatki',
|
||||
'admin.addons.title': 'Dodatki',
|
||||
'admin.addons.subtitle': 'Włączaj lub wyłączaj funkcje, aby dostosować swoje doświadczenie w TREK.',
|
||||
'admin.addons.catalog.packing.name': 'Lists',
|
||||
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
|
||||
'admin.addons.catalog.packing.name': 'Listy',
|
||||
'admin.addons.catalog.packing.description': 'Listy pakowania i zadania do wykonania dla Twoich podróży',
|
||||
'admin.addons.catalog.budget.name': 'Budżet',
|
||||
'admin.addons.catalog.budget.description': 'Śledź wydatki i planuj budżet podróży',
|
||||
'admin.addons.catalog.documents.name': 'Dokumenty',
|
||||
@@ -472,7 +482,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.addons.catalog.mcp.name': 'MCP',
|
||||
'admin.addons.catalog.mcp.description': 'Model Context Protocol dla integracji asystenta AI',
|
||||
'admin.addons.subtitleBefore': 'Włączaj lub wyłączaj funkcje, aby dostosować swoje doświadczenie w ',
|
||||
'admin.addons.subtitleAfter': '',
|
||||
'admin.addons.subtitleAfter': '.',
|
||||
'admin.addons.enabled': 'Włączone',
|
||||
'admin.addons.disabled': 'Wyłączone',
|
||||
'admin.addons.type.trip': 'Podróż',
|
||||
@@ -651,8 +661,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Usuń',
|
||||
'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?',
|
||||
'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?',
|
||||
'atlas.confirmUnmarkRegion': 'Usunąć ten region z listy odwiedzonych?',
|
||||
'atlas.markVisited': 'Oznacz jako odwiedzony',
|
||||
'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych',
|
||||
'atlas.markRegionVisitedHint': 'Dodaj ten region do listy odwiedzonych',
|
||||
'atlas.addToBucket': 'Dodaj do listy marzeń',
|
||||
'atlas.addPoi': 'Dodaj miejsce',
|
||||
'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)',
|
||||
@@ -701,8 +713,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.tabs.reservationsShort': 'Rezerwacje',
|
||||
'trip.tabs.packing': 'Lista pakowania',
|
||||
'trip.tabs.packingShort': 'Pakowanie',
|
||||
'trip.tabs.lists': 'Lists',
|
||||
'trip.tabs.listsShort': 'Lists',
|
||||
'trip.tabs.lists': 'Listy',
|
||||
'trip.tabs.listsShort': 'Listy',
|
||||
'trip.tabs.budget': 'Budżet',
|
||||
'trip.tabs.files': 'Pliki',
|
||||
'trip.loading': 'Ładowanie podróży...',
|
||||
@@ -890,11 +902,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.linkAssignment': 'Przypisz do miejsca',
|
||||
'reservations.pickAssignment': 'Wybierz miejsce z planu...',
|
||||
'reservations.noAssignment': 'Brak przypisania (samodzielna)',
|
||||
'reservations.price': 'Price',
|
||||
'reservations.budgetCategory': 'Budget category',
|
||||
'reservations.budgetCategoryPlaceholder': 'e.g. Transport, Accommodation',
|
||||
'reservations.budgetCategoryAuto': 'Auto (from booking type)',
|
||||
'reservations.budgetHint': 'A budget entry will be created automatically when saving.',
|
||||
'reservations.price': 'Cena',
|
||||
'reservations.budgetCategory': 'Kategoria budżetu',
|
||||
'reservations.budgetCategoryPlaceholder': 'np. Transport, Zakwaterowanie',
|
||||
'reservations.budgetCategoryAuto': 'Auto (na podstawie typu rezerwacji)',
|
||||
'reservations.budgetHint': 'Wpis budżetowy zostanie automatycznie utworzony podczas zapisywania.',
|
||||
'reservations.departureDate': 'Wylot',
|
||||
'reservations.arrivalDate': 'Przylot',
|
||||
'reservations.departureTime': 'Godz. wylotu',
|
||||
@@ -1443,8 +1455,31 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.testWebhook': 'Wyślij testowy webhook',
|
||||
'admin.notifications.testWebhookSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||
'admin.notifications.testWebhookFailed': 'Testowy webhook nie powiódł się',
|
||||
'admin.webhook.hint': 'Wysyłaj powiadomienia do zewnętrznego webhooka.',
|
||||
'admin.notifications.emailPanel.title': 'Email (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': 'In-App',
|
||||
'admin.notifications.inappPanel.hint': 'Powiadomienia w aplikacji są zawsze aktywne i nie można ich globalnie wyłączyć.',
|
||||
'admin.notifications.adminWebhookPanel.title': 'Webhook admina',
|
||||
'admin.notifications.adminWebhookPanel.hint': 'Ten webhook służy wyłącznie do powiadomień admina (np. alertów o nowych wersjach). Jest niezależny od webhooków użytkowników i wysyła automatycznie, gdy URL jest skonfigurowany.',
|
||||
'admin.notifications.adminWebhookPanel.saved': 'URL webhooka admina zapisany',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Webhook admina wysyła automatycznie, gdy URL jest skonfigurowany',
|
||||
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
|
||||
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
|
||||
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
||||
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
|
||||
'settings.webhookUrl.label': 'URL webhooka',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': 'Wprowadź adres URL webhooka Discord, Slack lub własnego, aby otrzymywać powiadomienia.',
|
||||
'settings.webhookUrl.save': 'Zapisz',
|
||||
'settings.webhookUrl.saved': 'URL webhooka zapisany',
|
||||
'settings.webhookUrl.test': 'Test',
|
||||
'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie',
|
||||
'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
|
||||
'settings.notificationPreferences.inapp': 'In-App',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.email': 'Email',
|
||||
'settings.notificationsActive': 'Aktywny kanał',
|
||||
'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.',
|
||||
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
|
||||
@@ -1541,13 +1576,16 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.deleteAll': 'Usuń wszystkie',
|
||||
'notifications.showAll': 'Pokaż wszystkie',
|
||||
'notifications.empty': 'Brak powiadomień',
|
||||
'notifications.emptyDescription': "You're all caught up!",
|
||||
'notifications.emptyDescription': 'Jesteś na bieżąco!',
|
||||
'notifications.all': 'Wszystkie',
|
||||
'notifications.unreadOnly': 'Nieprzeczytane',
|
||||
'notifications.markRead': 'Oznacz jako przeczytane',
|
||||
'notifications.markUnread': 'Oznacz jako nieprzeczytane',
|
||||
'notifications.delete': 'Usuń',
|
||||
'notifications.system': 'System',
|
||||
'notifications.versionAvailable.title': 'Dostępna aktualizacja',
|
||||
'notifications.versionAvailable.text': 'TREK {version} jest już dostępny.',
|
||||
'notifications.versionAvailable.button': 'Zobacz szczegóły',
|
||||
'notifications.test.title': 'Testowe powiadomienie od {actor}',
|
||||
'notifications.test.text': 'To jest powiadomienie testowe.',
|
||||
'notifications.test.booleanTitle': '{actor} prosi o akceptację',
|
||||
@@ -1563,38 +1601,75 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notifications.test.tripText': 'Testowe powiadomienie dla podróży "{trip}".',
|
||||
|
||||
// Todo
|
||||
'todo.subtab.packing': 'Packing List',
|
||||
'todo.subtab.todo': 'To-Do',
|
||||
'todo.completed': 'completed',
|
||||
'todo.filter.all': 'All',
|
||||
'todo.filter.open': 'Open',
|
||||
'todo.filter.done': 'Done',
|
||||
'todo.uncategorized': 'Uncategorized',
|
||||
'todo.namePlaceholder': 'Task name',
|
||||
'todo.descriptionPlaceholder': 'Description (optional)',
|
||||
'todo.unassigned': 'Unassigned',
|
||||
'todo.noCategory': 'No category',
|
||||
'todo.hasDescription': 'Has description',
|
||||
'todo.addItem': 'Add new task...',
|
||||
'todo.newCategory': 'Category name',
|
||||
'todo.addCategory': 'Add category',
|
||||
'todo.newItem': 'New task',
|
||||
'todo.empty': 'No tasks yet. Add a task to get started!',
|
||||
'todo.filter.my': 'My Tasks',
|
||||
'todo.filter.overdue': 'Overdue',
|
||||
'todo.sidebar.tasks': 'Tasks',
|
||||
'todo.sidebar.categories': 'Categories',
|
||||
'todo.detail.title': 'Task',
|
||||
'todo.detail.description': 'Description',
|
||||
'todo.detail.category': 'Category',
|
||||
'todo.detail.dueDate': 'Due date',
|
||||
'todo.detail.assignedTo': 'Assigned to',
|
||||
'todo.detail.delete': 'Delete',
|
||||
'todo.detail.save': 'Save changes',
|
||||
'todo.detail.create': 'Create task',
|
||||
'todo.detail.priority': 'Priority',
|
||||
'todo.detail.noPriority': 'None',
|
||||
'todo.sortByPrio': 'Priority',
|
||||
'todo.subtab.packing': 'Lista pakowania',
|
||||
'todo.subtab.todo': 'Do zrobienia',
|
||||
'todo.completed': 'ukończono',
|
||||
'todo.filter.all': 'Wszystkie',
|
||||
'todo.filter.open': 'Otwarte',
|
||||
'todo.filter.done': 'Gotowe',
|
||||
'todo.uncategorized': 'Bez kategorii',
|
||||
'todo.namePlaceholder': 'Nazwa zadania',
|
||||
'todo.descriptionPlaceholder': 'Opis (opcjonalnie)',
|
||||
'todo.unassigned': 'Nieprzypisane',
|
||||
'todo.noCategory': 'Brak kategorii',
|
||||
'todo.hasDescription': 'Ma opis',
|
||||
'todo.addItem': 'Dodaj nowe zadanie...',
|
||||
'todo.newCategory': 'Nazwa kategorii',
|
||||
'todo.addCategory': 'Dodaj kategorię',
|
||||
'todo.newItem': 'Nowe zadanie',
|
||||
'todo.empty': 'Brak zadań. Dodaj zadanie, aby zacząć!',
|
||||
'todo.filter.my': 'Moje zadania',
|
||||
'todo.filter.overdue': 'Przeterminowane',
|
||||
'todo.sidebar.tasks': 'Zadania',
|
||||
'todo.sidebar.categories': 'Kategorie',
|
||||
'todo.detail.title': 'Zadanie',
|
||||
'todo.detail.description': 'Opis',
|
||||
'todo.detail.category': 'Kategoria',
|
||||
'todo.detail.dueDate': 'Termin',
|
||||
'todo.detail.assignedTo': 'Przypisano do',
|
||||
'todo.detail.delete': 'Usuń',
|
||||
'todo.detail.save': 'Zapisz zmiany',
|
||||
'todo.detail.create': 'Utwórz zadanie',
|
||||
'todo.detail.priority': 'Priorytet',
|
||||
'todo.detail.noPriority': 'Brak',
|
||||
'todo.sortByPrio': 'Priorytet',
|
||||
|
||||
// Notifications — dev test events
|
||||
'notif.test.title': '[Test] Powiadomienie',
|
||||
'notif.test.simple.text': 'To jest proste powiadomienie testowe.',
|
||||
'notif.test.boolean.text': 'Czy akceptujesz to powiadomienie testowe?',
|
||||
'notif.test.navigate.text': 'Kliknij poniżej, aby przejść do pulpitu.',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': 'Zaproszenie do podróży',
|
||||
'notif.trip_invite.text': '{actor} zaprosił Cię do {trip}',
|
||||
'notif.booking_change.title': 'Rezerwacja zaktualizowana',
|
||||
'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}',
|
||||
'notif.trip_reminder.title': 'Przypomnienie o podróży',
|
||||
'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!',
|
||||
'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion',
|
||||
'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych',
|
||||
'notif.photos_shared.title': 'Zdjęcia udostępnione',
|
||||
'notif.photos_shared.text': '{actor} udostępnił {count} zdjęcie/zdjęcia w {trip}',
|
||||
'notif.collab_message.title': 'Nowa wiadomość',
|
||||
'notif.collab_message.text': '{actor} wysłał wiadomość w {trip}',
|
||||
'notif.packing_tagged.title': 'Zadanie pakowania',
|
||||
'notif.packing_tagged.text': '{actor} przypisał Cię do {category} w {trip}',
|
||||
'notif.version_available.title': 'Nowa wersja dostępna',
|
||||
'notif.version_available.text': 'TREK {version} jest teraz dostępny',
|
||||
'notif.action.view_trip': 'Zobacz podróż',
|
||||
'notif.action.view_collab': 'Zobacz wiadomości',
|
||||
'notif.action.view_packing': 'Zobacz pakowanie',
|
||||
'notif.action.view_photos': 'Zobacz zdjęcia',
|
||||
'notif.action.view_vacay': 'Zobacz Vacay',
|
||||
'notif.action.view_admin': 'Przejdź do admina',
|
||||
'notif.action.view': 'Zobacz',
|
||||
'notif.action.accept': 'Akceptuj',
|
||||
'notif.action.decline': 'Odrzuć',
|
||||
'notif.generic.title': 'Powiadomienie',
|
||||
'notif.generic.text': 'Masz nowe powiadomienie',
|
||||
'notif.dev.unknown_event.title': '[DEV] Nieznane zdarzenie',
|
||||
'notif.dev.unknown_event.text': 'Typ zdarzenia "{event}" nie jest zarejestrowany w EVENT_NOTIFICATION_CONFIG',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,107 @@ interface UpdateInfo {
|
||||
is_docker?: boolean
|
||||
}
|
||||
|
||||
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
}
|
||||
|
||||
const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
inapp: 'settings.notificationPreferences.inapp',
|
||||
email: 'settings.notificationPreferences.email',
|
||||
webhook: 'settings.notificationPreferences.webhook',
|
||||
}
|
||||
|
||||
function AdminNotificationsPanel({ t, toast }: { t: (k: string) => string; toast: ReturnType<typeof useToast> }) {
|
||||
const [matrix, setMatrix] = useState<any>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getNotificationPreferences().then((data: any) => setMatrix(data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
||||
|
||||
const visibleChannels = (['inapp', 'email', 'webhook'] as const).filter(ch => {
|
||||
if (!matrix.available_channels[ch]) return false
|
||||
return matrix.event_types.some((evt: string) => matrix.implemented_combos[evt]?.includes(ch))
|
||||
})
|
||||
|
||||
const toggle = async (eventType: string, channel: string) => {
|
||||
const current = matrix.preferences[eventType]?.[channel] ?? true
|
||||
const updated = { ...matrix.preferences, [eventType]: { ...matrix.preferences[eventType], [channel]: !current } }
|
||||
setMatrix((m: any) => m ? { ...m, preferences: updated } : m)
|
||||
setSaving(true)
|
||||
try {
|
||||
await adminApi.updateNotificationPreferences(updated)
|
||||
} catch {
|
||||
setMatrix((m: any) => m ? { ...m, preferences: matrix.preferences } : m)
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (matrix.event_types.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)' }}>{t('settings.notificationPreferences.noChannels')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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.adminNotifications')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.adminNotificationsHint')}</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving…</p>}
|
||||
{/* Header row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, paddingBottom: 6, marginBottom: 4, borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span />
|
||||
{visibleChannels.map(ch => (
|
||||
<span key={ch} style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textAlign: 'center', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{t(ADMIN_CHANNEL_LABEL_KEYS[ch]) || ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Event rows */}
|
||||
{matrix.event_types.map((eventType: string) => {
|
||||
const implementedForEvent = matrix.implemented_combos[eventType] ?? []
|
||||
return (
|
||||
<div key={eventType} style={{ display: 'grid', gridTemplateColumns: `1fr ${visibleChannels.map(() => '80px').join(' ')}`, gap: 4, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border-primary)' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t(ADMIN_EVENT_LABEL_KEYS[eventType]) || eventType}
|
||||
</span>
|
||||
{visibleChannels.map(ch => {
|
||||
if (!implementedForEvent.includes(ch)) {
|
||||
return <span key={ch} style={{ textAlign: 'center', color: 'var(--text-faint)', fontSize: 14 }}>—</span>
|
||||
}
|
||||
const isOn = matrix.preferences[eventType]?.[ch] ?? true
|
||||
return (
|
||||
<div key={ch} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<button
|
||||
onClick={() => toggle(eventType, ch)}
|
||||
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode, serverTimezone } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -68,6 +169,8 @@ export default function AdminPage(): React.ReactElement {
|
||||
{ id: 'config', label: t('admin.tabs.config') },
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'notification-channels', label: t('admin.tabs.notificationChannels') },
|
||||
{ id: 'admin-notifications', label: t('admin.tabs.adminNotifications') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'audit', label: t('admin.tabs.audit') },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []),
|
||||
@@ -969,189 +1072,6 @@ export default function AdminPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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.notifications.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.hint')}</p>
|
||||
</div>
|
||||
<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"
|
||||
style={{ background: isOn ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: isOn ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</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"
|
||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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] !== 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')) }
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
|
||||
@@ -1179,6 +1099,209 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notification-channels' && (() => {
|
||||
// Derive active channels from smtpValues.notification_channels (plural)
|
||||
// with fallback to notification_channel (singular) for existing installs
|
||||
const rawChannels = smtpValues.notification_channels ?? smtpValues.notification_channel ?? 'none'
|
||||
const activeChans = rawChannels === 'none' ? [] : rawChannels.split(',').map((c: string) => c.trim())
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean) => {
|
||||
const chans = [email && 'email', webhook && 'webhook'].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: chans }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notification_channels: chans })
|
||||
} catch {
|
||||
// Revert state on failure
|
||||
const reverted = [emailActive && 'email', webhookActive && 'webhook'].filter(Boolean).join(',') || 'none'
|
||||
setSmtpValues(prev => ({ ...prev, notification_channels: reverted }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const smtpConfigured = !!(smtpValues.smtp_host?.trim())
|
||||
const saveNotifications = async () => {
|
||||
// Saves credentials only — channel activation is auto-saved by the toggle
|
||||
const notifKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify']
|
||||
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')) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 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>
|
||||
<button
|
||||
onClick={() => setChannels(!emailActive, webhookActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: emailActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: emailActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</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>
|
||||
<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"
|
||||
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<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 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
|
||||
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"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook Panel */}
|
||||
<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.webhookPanel.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.webhook.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setChannels(emailActive, !webhookActive)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: webhookActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: webhookActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
style={{ background: 'var(--text-primary)', 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>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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
|
||||
onClick={async () => {
|
||||
const url = smtpValues.admin_webhook_url === '••••••••' ? undefined : smtpValues.admin_webhook_url
|
||||
if (!url && smtpValues.admin_webhook_url !== '••••••••') return
|
||||
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.smtp.testButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{activeTab === 'admin-notifications' && <AdminNotificationsPanel t={t} toast={toast} />}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'audit' && <AuditLogPanel serverTimezone={serverTimezone} />}
|
||||
|
||||
@@ -154,7 +154,16 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
||||
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
|
||||
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
|
||||
const regionLayerRef = useRef<L.GeoJSON | null>(null)
|
||||
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
|
||||
const [showRegions, setShowRegions] = useState(false)
|
||||
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
|
||||
const regionTooltipRef = useRef<HTMLDivElement>(null)
|
||||
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
|
||||
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
|
||||
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null)
|
||||
const [bucketMonth, setBucketMonth] = useState(0)
|
||||
const [bucketYear, setBucketYear] = useState(0)
|
||||
|
||||
@@ -221,6 +230,41 @@ export default function AtlasPage(): React.ReactElement {
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load visited regions (geocoded from places/trips) — once on mount
|
||||
useEffect(() => {
|
||||
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
|
||||
.then(r => setVisitedRegions(r.data?.regions || {}))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load admin-1 GeoJSON for countries visible in the current viewport
|
||||
const loadRegionsForViewportRef = useRef<() => void>(() => {})
|
||||
const loadRegionsForViewport = (): void => {
|
||||
if (!mapInstance.current) return
|
||||
const bounds = mapInstance.current.getBounds()
|
||||
const toLoad: string[] = []
|
||||
for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) {
|
||||
if (regionGeoCache.current[code]) continue
|
||||
try {
|
||||
if (bounds.intersects((layer as any).getBounds())) toLoad.push(code)
|
||||
} catch {}
|
||||
}
|
||||
if (!toLoad.length) return
|
||||
apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`)
|
||||
.then(geoRes => {
|
||||
const geo = geoRes.data
|
||||
if (!geo?.features) return
|
||||
let added = false
|
||||
for (const c of toLoad) {
|
||||
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
|
||||
if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true }
|
||||
}
|
||||
if (added) setRegionGeoLoaded(v => v + 1)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
loadRegionsForViewportRef.current = loadRegionsForViewport
|
||||
|
||||
// Initialize map — runs after loading is done and mapRef is available
|
||||
useEffect(() => {
|
||||
if (loading || !mapRef.current) return
|
||||
@@ -230,7 +274,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
center: [25, 0],
|
||||
zoom: 3,
|
||||
minZoom: 3,
|
||||
maxZoom: 7,
|
||||
maxZoom: 10,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
maxBounds: [[-90, -220], [90, 220]],
|
||||
@@ -246,7 +290,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 8,
|
||||
maxZoom: 10,
|
||||
keepBuffer: 25,
|
||||
updateWhenZooming: true,
|
||||
updateWhenIdle: false,
|
||||
@@ -257,14 +301,49 @@ export default function AtlasPage(): React.ReactElement {
|
||||
|
||||
// Preload adjacent zoom level tiles
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 8,
|
||||
maxZoom: 10,
|
||||
keepBuffer: 10,
|
||||
opacity: 0,
|
||||
tileSize: 256,
|
||||
crossOrigin: true,
|
||||
}).addTo(map)
|
||||
|
||||
// Custom pane for region layer — above overlay (z-index 400)
|
||||
map.createPane('regionPane')
|
||||
map.getPane('regionPane')!.style.zIndex = '401'
|
||||
|
||||
mapInstance.current = map
|
||||
|
||||
// Zoom-based region switching
|
||||
map.on('zoomend', () => {
|
||||
const z = map.getZoom()
|
||||
const shouldShow = z >= 5
|
||||
setShowRegions(shouldShow)
|
||||
const overlayPane = map.getPane('overlayPane')
|
||||
if (overlayPane) {
|
||||
overlayPane.style.opacity = shouldShow ? '0.35' : '1'
|
||||
overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto'
|
||||
}
|
||||
if (shouldShow) {
|
||||
// Re-add region layer if it was removed while zoomed out
|
||||
if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) {
|
||||
regionLayerRef.current.addTo(map)
|
||||
}
|
||||
loadRegionsForViewportRef.current()
|
||||
} else {
|
||||
// Physically remove region layer so its SVG paths can't intercept events
|
||||
if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none'
|
||||
if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) {
|
||||
regionLayerRef.current.resetStyle()
|
||||
regionLayerRef.current.removeFrom(map)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
map.on('moveend', () => {
|
||||
if (map.getZoom() >= 6) loadRegionsForViewportRef.current()
|
||||
})
|
||||
|
||||
return () => { map.remove(); mapInstance.current = null }
|
||||
}, [dark, loading])
|
||||
|
||||
@@ -339,10 +418,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
})
|
||||
layer.on('click', () => {
|
||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||
// Manually marked only — show unmark popup
|
||||
handleUnmarkCountry(c.code)
|
||||
} else {
|
||||
loadCountryDetail(c.code)
|
||||
}
|
||||
})
|
||||
layer.on('mouseover', (e) => {
|
||||
@@ -379,9 +455,153 @@ export default function AtlasPage(): React.ReactElement {
|
||||
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
|
||||
}, [geoData, data, dark])
|
||||
|
||||
// Render sub-national region layer (zoom >= 5)
|
||||
useEffect(() => {
|
||||
if (!mapInstance.current) return
|
||||
|
||||
// Remove existing region layer
|
||||
if (regionLayerRef.current) {
|
||||
mapInstance.current.removeLayer(regionLayerRef.current)
|
||||
regionLayerRef.current = null
|
||||
}
|
||||
|
||||
if (Object.keys(regionGeoCache.current).length === 0) return
|
||||
|
||||
// Build set of visited region codes first
|
||||
const visitedRegionCodes = new Set<string>()
|
||||
const visitedRegionNames = new Set<string>()
|
||||
const regionPlaceCounts: Record<string, number> = {}
|
||||
for (const [, regions] of Object.entries(visitedRegions)) {
|
||||
for (const r of regions) {
|
||||
visitedRegionCodes.add(r.code)
|
||||
visitedRegionNames.add(r.name.toLowerCase())
|
||||
regionPlaceCounts[r.code] = r.placeCount
|
||||
regionPlaceCounts[r.name.toLowerCase()] = r.placeCount
|
||||
}
|
||||
}
|
||||
|
||||
// Match feature by ISO code OR region name
|
||||
const isVisitedFeature = (f: any) => {
|
||||
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
|
||||
const name = (f.properties?.name || '').toLowerCase()
|
||||
if (visitedRegionNames.has(name)) return true
|
||||
// Fuzzy: check if any visited name is contained in feature name or vice versa
|
||||
for (const vn of visitedRegionNames) {
|
||||
if (name.includes(vn) || vn.includes(name)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Include ALL region features — visited ones get colored fill, unvisited get outline only
|
||||
const allFeatures: any[] = []
|
||||
for (const geo of Object.values(regionGeoCache.current)) {
|
||||
for (const f of geo.features) {
|
||||
allFeatures.push(f)
|
||||
}
|
||||
}
|
||||
if (allFeatures.length === 0) return
|
||||
|
||||
// Use same colors as country layer
|
||||
const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef']
|
||||
const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : []
|
||||
const countryColorMap: Record<string, string> = {}
|
||||
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
|
||||
// Map country A2 code to country color
|
||||
const a2ColorMap: Record<string, string> = {}
|
||||
if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
|
||||
|
||||
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
|
||||
|
||||
const svgRenderer = L.svg({ pane: 'regionPane' })
|
||||
|
||||
regionLayerRef.current = L.geoJSON(mergedGeo as any, {
|
||||
renderer: svgRenderer,
|
||||
interactive: true,
|
||||
pane: 'regionPane',
|
||||
style: (feature) => {
|
||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||
const visited = isVisitedFeature(feature)
|
||||
return visited ? {
|
||||
fillColor: a2ColorMap[countryA2] || '#6366f1',
|
||||
fillOpacity: 0.85,
|
||||
color: dark ? '#888' : '#64748b',
|
||||
weight: 1.2,
|
||||
} : {
|
||||
fillColor: dark ? '#ffffff' : '#000000',
|
||||
fillOpacity: 0.03,
|
||||
color: dark ? '#555' : '#94a3b8',
|
||||
weight: 1,
|
||||
}
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
const regionName = feature?.properties?.name || ''
|
||||
const countryName = feature?.properties?.admin || ''
|
||||
const regionCode = feature?.properties?.iso_3166_2 || ''
|
||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||
const visited = isVisitedFeature(feature)
|
||||
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0
|
||||
layer.on('click', () => {
|
||||
if (!countryA2) return
|
||||
if (visited) {
|
||||
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
|
||||
if (regionEntry?.manuallyMarked) {
|
||||
setConfirmActionRef.current({
|
||||
type: 'unmark-region',
|
||||
code: countryA2,
|
||||
name: regionName,
|
||||
regionCode,
|
||||
countryName,
|
||||
})
|
||||
} else {
|
||||
loadCountryDetailRef.current(countryA2)
|
||||
}
|
||||
} else {
|
||||
setConfirmActionRef.current({
|
||||
type: 'choose-region',
|
||||
code: countryA2, // country A2 code — used for flag display
|
||||
name: regionName, // region name — shown as heading
|
||||
regionCode,
|
||||
countryName,
|
||||
})
|
||||
}
|
||||
})
|
||||
layer.on('mouseover', (e: any) => {
|
||||
e.target.setStyle(visited
|
||||
? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }
|
||||
: { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' }
|
||||
)
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) {
|
||||
tt.style.display = 'block'
|
||||
tt.style.left = e.originalEvent.clientX + 12 + 'px'
|
||||
tt.style.top = e.originalEvent.clientY - 10 + 'px'
|
||||
tt.innerHTML = visited
|
||||
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
|
||||
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
|
||||
}
|
||||
})
|
||||
layer.on('mousemove', (e: any) => {
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' }
|
||||
})
|
||||
layer.on('mouseout', (e: any) => {
|
||||
regionLayerRef.current?.resetStyle(e.target)
|
||||
const tt = regionTooltipRef.current
|
||||
if (tt) tt.style.display = 'none'
|
||||
})
|
||||
},
|
||||
})
|
||||
// Only add to map if currently in region mode — otherwise hold it ready for when user zooms in
|
||||
if (mapInstance.current.getZoom() >= 6) {
|
||||
regionLayerRef.current.addTo(mapInstance.current)
|
||||
}
|
||||
}, [regionGeoLoaded, visitedRegions, dark, t])
|
||||
|
||||
const handleMarkCountry = (code: string, name: string): void => {
|
||||
setConfirmAction({ type: 'choose', code, name })
|
||||
}
|
||||
handleMarkCountryRef.current = handleMarkCountry
|
||||
setConfirmActionRef.current = setConfirmAction
|
||||
|
||||
const handleUnmarkCountry = (code: string): void => {
|
||||
const country = data?.countries.find(c => c.code === code)
|
||||
@@ -435,6 +655,12 @@ export default function AtlasPage(): React.ReactElement {
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
}
|
||||
})
|
||||
setVisitedRegions(prev => {
|
||||
if (!prev[code]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[code]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +738,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
setCountryDetail(r.data)
|
||||
} catch { /* */ }
|
||||
}
|
||||
loadCountryDetailRef.current = loadCountryDetail
|
||||
|
||||
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
||||
const countries = data?.countries || []
|
||||
@@ -533,6 +760,18 @@ export default function AtlasPage(): React.ReactElement {
|
||||
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
||||
{/* Map */}
|
||||
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||
|
||||
{/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */}
|
||||
<div ref={regionTooltipRef} style={{
|
||||
position: 'fixed', display: 'none',
|
||||
zIndex: 9999, pointerEvents: 'none',
|
||||
background: dark ? 'rgba(15,15,20,0.92)' : 'rgba(255,255,255,0.96)',
|
||||
color: dark ? '#fff' : '#111',
|
||||
borderRadius: 10, padding: '10px 14px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`,
|
||||
fontSize: 12, minWidth: 120,
|
||||
}} />
|
||||
<div
|
||||
className="absolute z-20 flex justify-center"
|
||||
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
|
||||
@@ -769,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'choose-region' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{confirmAction.countryName && (
|
||||
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||
)}
|
||||
<button onClick={async () => {
|
||||
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
|
||||
setVisitedRegions(prev => {
|
||||
const existing = prev[countryCode] || []
|
||||
if (existing.find(r => r.code === rCode)) return prev
|
||||
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
|
||||
})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||
})
|
||||
} catch {}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark' && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
|
||||
@@ -785,6 +1068,51 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<>
|
||||
{confirmAction.countryName && (
|
||||
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||
)}
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmarkRegion')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button onClick={() => setConfirmAction(null)}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
const { code: countryCode, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
|
||||
setVisitedRegions(prev => {
|
||||
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
|
||||
const next = { ...prev, [countryCode]: remaining }
|
||||
if (remaining.length === 0) delete next[countryCode]
|
||||
return next
|
||||
})
|
||||
// If no manually-marked regions remain, also remove country if it has no trips/places
|
||||
setData(prev => {
|
||||
if (!prev) return prev
|
||||
const c = prev.countries.find(c => c.code === countryCode)
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||
if (remainingRegions.length > 0) return prev
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
|
||||
{t('atlas.unmark')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
|
||||
@@ -815,7 +1143,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
@@ -34,6 +34,16 @@ export default function LoginPage(): React.ReactElement {
|
||||
const { setLanguageLocal } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const redirectTarget = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const redirect = params.get('redirect')
|
||||
// Only allow relative paths starting with / to prevent open redirect attacks
|
||||
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) {
|
||||
return redirect
|
||||
}
|
||||
return '/dashboard'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
@@ -99,7 +109,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
try {
|
||||
await demoLogin()
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
setTimeout(() => navigate(redirectTarget), 2600)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('login.demoFailed'))
|
||||
} finally {
|
||||
@@ -128,7 +138,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword })
|
||||
await loadUser({ silent: true })
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
setTimeout(() => navigate(redirectTarget), 2600)
|
||||
return
|
||||
}
|
||||
if (mode === 'login' && mfaStep) {
|
||||
@@ -145,7 +155,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
return
|
||||
}
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
setTimeout(() => navigate(redirectTarget), 2600)
|
||||
return
|
||||
}
|
||||
if (mode === 'register') {
|
||||
@@ -169,7 +179,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
setTimeout(() => navigate(redirectTarget), 2600)
|
||||
} catch (err: unknown) {
|
||||
setError(getApiErrorMessage(err, t('login.error')))
|
||||
setIsLoading(false)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
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, Info } from 'lucide-react'
|
||||
import { authApi, adminApi } from '../api/client'
|
||||
import apiClient from '../api/client'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { UserWithOidc } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { MapView } from '../components/Map/MapView'
|
||||
import type { Place } from '../types'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import DisplaySettingsTab from '../components/Settings/DisplaySettingsTab'
|
||||
import MapSettingsTab from '../components/Settings/MapSettingsTab'
|
||||
import NotificationsTab from '../components/Settings/NotificationsTab'
|
||||
import IntegrationsTab from '../components/Settings/IntegrationsTab'
|
||||
import AccountTab from '../components/Settings/AccountTab'
|
||||
import AboutTab from '../components/Settings/AboutTab'
|
||||
|
||||
interface MapPreset {
|
||||
name: string
|
||||
@@ -140,7 +141,7 @@ function NotificationPreferences({ t }: { t: any; memoriesEnabled: boolean }) {
|
||||
}
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
@@ -150,9 +151,6 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Addon gating (derived from store)
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
@@ -163,14 +161,12 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const [providerValues, setProviderValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [providerConnected, setProviderConnected] = useState<Record<string, boolean>>({})
|
||||
const [providerTesting, setProviderTesting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
setDefaultLng(mapInfo.latlng.lng)
|
||||
}, [])
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled
|
||||
const [activeTab, setActiveTab] = useState('display')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
}, [])
|
||||
const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => {
|
||||
const raw = provider.config || {}
|
||||
@@ -604,16 +600,37 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-switch to account tab when MFA is required
|
||||
useEffect(() => {
|
||||
if (searchParams.get('mfa') === 'required') {
|
||||
setActiveTab('account')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const TABS = [
|
||||
{ id: 'display', label: t('settings.tabs.display') },
|
||||
{ id: 'map', label: t('settings.tabs.map') },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []),
|
||||
{ id: 'account', label: t('settings.tabs.account') },
|
||||
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<Navbar />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<style>{`@media (max-width: 900px) { .settings-columns { column-count: 1 !important; } }`}</style>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-tertiary)' }}>
|
||||
<Settings className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-columns" style={{ columnCount: 2, columnGap: 24 }}>
|
||||
@@ -1385,147 +1402,30 @@ export default function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12 }}>
|
||||
{/* Tab bar */}
|
||||
<div className="grid grid-cols-3 sm:flex gap-1 mb-6 rounded-xl p-1" style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)' }}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving.profile}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{saving.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
|
||||
<span className="sm:hidden">{t('common.save')}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
const data = await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-red-500 hover:bg-red-50"
|
||||
style={{ border: '1px solid #fecaca' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
|
||||
<span className="sm:hidden">{t('common.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{appVersion && (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '6px 14px' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)' }}>TREK</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||
</div>
|
||||
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
title="Discord">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--text-faint)"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation */}
|
||||
{showDeleteConfirm === 'blocked' && (
|
||||
<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: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} style={{ color: '#d97706' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteBlockedTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteBlockedMessage')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.ok') || 'OK'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm === true && (
|
||||
<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: 24,
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('settings.deleteAccountTitle')}</h3>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0 0 20px' }}>
|
||||
{t('settings.deleteAccountWarning')}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
|
||||
border: 'none', background: '#ef4444', color: 'white',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('settings.deleteAccountConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'display' && <DisplaySettingsTab />}
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user