mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 05:41:47 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 504195a324 | |||
| 47b880221d | |||
| a6ea73eab6 | |||
| 4ba6005ca3 | |||
| 09ab829b17 | |||
| 66a057a070 | |||
| f2ffea5ba4 | |||
| b0dee4dafb | |||
| beb48af8ed | |||
| e2be3ec191 | |||
| 68a1f9683e | |||
| 5c57116a68 |
@@ -161,6 +161,7 @@ services:
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -303,6 +304,7 @@ trek.yourdomain.com {
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` |
|
||||
|
||||
## Optional API Keys
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ env:
|
||||
# Enable demo mode (hourly data resets).
|
||||
# MCP_RATE_LIMIT: "60"
|
||||
# Max MCP API requests per user per minute. Defaults to 60.
|
||||
# MCP_MAX_SESSION_PER_USER: "5"
|
||||
# Max concurrent MCP sessions per user. Defaults to 5.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.9.5",
|
||||
"version": "2.9.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "2.9.5",
|
||||
"version": "2.9.11",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.9.5",
|
||||
"version": "2.9.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ export default function App() {
|
||||
const { loadSettings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/')) {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) {
|
||||
loadUser()
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
}
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
|
||||
@@ -248,8 +248,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
return reservations.filter(r => {
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
|
||||
@@ -341,14 +343,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
initTransportPositions(dayId)
|
||||
}
|
||||
|
||||
// Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order
|
||||
// Places keep their order_index ordering — only transports are inserted based on time.
|
||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
||||
const baseItems = [
|
||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
|
||||
// Only transports are inserted among base items based on time/position
|
||||
// Transports are inserted among places based on time
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
@@ -360,22 +361,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
||||
}
|
||||
|
||||
// Insert transports among base items using persisted position or time-to-position mapping.
|
||||
// Insert transports among places based on per-day position or time
|
||||
const result = [...baseItems]
|
||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||
const timed = timedTransports[ti]
|
||||
const minutes = timed.minutes
|
||||
|
||||
// Use per-day position if available, fallback to global position
|
||||
const dayObj = days.find(d => d.id === dayId)
|
||||
// Use per-day position if explicitly set by user reorder
|
||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||
const effectivePos = perDayPos ?? timed.data.day_plan_position
|
||||
if (effectivePos != null) {
|
||||
result.push({ type: timed.type, sortKey: effectivePos, data: timed.data })
|
||||
if (perDayPos != null) {
|
||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||
continue
|
||||
}
|
||||
|
||||
// Find insertion position: after the last base item with time <= this transport's time
|
||||
// Find insertion position: after the last place with time <= this transport's time
|
||||
let insertAfterKey = -Infinity
|
||||
for (const item of result) {
|
||||
if (item.type === 'place') {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -113,6 +114,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
if (rawEnd.includes('T')) {
|
||||
endDate = rawEnd.split('T')[0]
|
||||
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
|
||||
} else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) {
|
||||
endDate = rawEnd
|
||||
endTime = ''
|
||||
}
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
@@ -166,6 +170,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const startDate = form.reservation_time.split('T')[0]
|
||||
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
||||
const endTime = form.reservation_end_time || '00:00'
|
||||
// For flights, compare in UTC using timezone offsets
|
||||
if (form.type === 'flight') {
|
||||
const parseOffset = (tz: string): number | null => {
|
||||
if (!tz) return null
|
||||
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
|
||||
if (!m) return null
|
||||
const sign = m[1] === '+' ? 1 : -1
|
||||
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
|
||||
}
|
||||
const depOffset = parseOffset(form.meta_departure_timezone)
|
||||
const arrOffset = parseOffset(form.meta_arrival_timezone)
|
||||
if (depOffset === null || arrOffset === null) return false
|
||||
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
|
||||
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
|
||||
return arrMinutes <= depMinutes
|
||||
}
|
||||
const startFull = `${startDate}T${startTime}`
|
||||
const endFull = `${form.end_date}T${endTime}`
|
||||
return endFull <= startFull
|
||||
@@ -204,7 +224,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
const saveData: Record<string, any> = {
|
||||
title: form.title, type: form.type, status: form.status,
|
||||
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
|
||||
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
|
||||
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
|
||||
location: form.location, confirmation_number: form.confirmation_number,
|
||||
notes: form.notes,
|
||||
assignment_id: form.assignment_id || null,
|
||||
@@ -364,7 +385,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const date = d || new Date().toISOString().split('T')[0]
|
||||
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
@@ -565,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
||||
<a href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||
<button type="button" onClick={async () => {
|
||||
// Always unlink, never delete the file
|
||||
// Clear primary reservation_id if it points to this reservation
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||
} from 'lucide-react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
@@ -138,7 +139,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time?.includes('T') && r.reservation_end_time.split('T')[0] !== r.reservation_time.split('T')[0] && (
|
||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
@@ -252,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
||||
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<a key={f.id} href="#" onClick={async (e) => { e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
|
||||
@@ -575,7 +575,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
try {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login')
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
|
||||
@@ -367,6 +367,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'فشل الدخول إلى العرض التجريبي',
|
||||
'login.oidcSignIn': 'تسجيل الدخول عبر {name}',
|
||||
'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.',
|
||||
'login.oidcLoggedOut': 'تم تسجيل خروجك. سجّل الدخول مجدداً عبر مزود SSO.',
|
||||
'login.demoHint': 'جرّب العرض التجريبي دون الحاجة للتسجيل',
|
||||
'login.mfaTitle': 'المصادقة الثنائية',
|
||||
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
|
||||
@@ -362,6 +362,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'Falha no login de demonstração',
|
||||
'login.oidcSignIn': 'Entrar com {name}',
|
||||
'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.',
|
||||
'login.oidcLoggedOut': 'Você foi desconectado. Entre novamente usando o provedor SSO.',
|
||||
'login.demoHint': 'Experimente a demonstração — sem cadastro',
|
||||
'login.mfaTitle': 'Autenticação em duas etapas',
|
||||
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
|
||||
|
||||
@@ -362,6 +362,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'Přihlášení do dema se nezdařilo',
|
||||
'login.oidcSignIn': 'Přihlásit se přes {name}',
|
||||
'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.',
|
||||
'login.oidcLoggedOut': 'Byl jste odhlášen. Přihlaste se znovu přes SSO poskytovatele.',
|
||||
'login.demoHint': 'Vyzkoušejte demo – registrace není nutná',
|
||||
'login.mfaTitle': 'Dvoufaktorové ověření',
|
||||
'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.',
|
||||
|
||||
@@ -362,6 +362,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'Demo-Login fehlgeschlagen',
|
||||
'login.oidcSignIn': 'Anmelden mit {name}',
|
||||
'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.',
|
||||
'login.oidcLoggedOut': 'Du wurdest abgemeldet. Melde dich erneut über deinen SSO-Anbieter an.',
|
||||
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
|
||||
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
|
||||
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
|
||||
|
||||
@@ -383,6 +383,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'Demo login failed',
|
||||
'login.oidcSignIn': 'Sign in with {name}',
|
||||
'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.',
|
||||
'login.oidcLoggedOut': 'You have been logged out. Sign in again using your SSO provider.',
|
||||
'login.demoHint': 'Try the demo — no registration needed',
|
||||
'login.mfaTitle': 'Two-factor authentication',
|
||||
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
|
||||
|
||||
@@ -1490,6 +1490,7 @@ const es: Record<string, string> = {
|
||||
'admin.oidcOnlyMode': 'Desactivar autenticación por contraseña',
|
||||
'admin.oidcOnlyModeHint': 'Si está activado, solo se permite el inicio de sesión con SSO. El inicio de sesión y registro con contraseña se bloquean.',
|
||||
'login.oidcOnly': 'La autenticación por contraseña está desactivada. Por favor, inicia sesión con tu proveedor SSO.',
|
||||
'login.oidcLoggedOut': 'Has cerrado sesión. Vuelve a iniciar sesión con tu proveedor SSO.',
|
||||
|
||||
// Settings (2.6.2)
|
||||
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
|
||||
|
||||
@@ -369,6 +369,7 @@ const fr: Record<string, string> = {
|
||||
'login.demoFailed': 'Échec de la connexion démo',
|
||||
'login.oidcSignIn': 'Se connecter avec {name}',
|
||||
'login.oidcOnly': 'L\'authentification par mot de passe est désactivée. Veuillez vous connecter via votre fournisseur SSO.',
|
||||
'login.oidcLoggedOut': 'Vous avez été déconnecté. Reconnectez-vous via votre fournisseur SSO.',
|
||||
'login.demoHint': 'Essayez la démo — aucune inscription nécessaire',
|
||||
|
||||
// Register
|
||||
|
||||
@@ -362,6 +362,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'Demo bejelentkezés sikertelen',
|
||||
'login.oidcSignIn': 'Bejelentkezés ezzel: {name}',
|
||||
'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.',
|
||||
'login.oidcLoggedOut': 'Kijelentkeztél. Jelentkezz be újra az SSO szolgáltatódon keresztül.',
|
||||
'login.demoHint': 'Próbáld ki a demót — regisztráció nélkül',
|
||||
'login.mfaTitle': 'Kétfaktoros hitelesítés',
|
||||
'login.mfaSubtitle': 'Add meg a 6 jegyű kódot a hitelesítő alkalmazásból.',
|
||||
|
||||
@@ -362,6 +362,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'Accesso demo fallito',
|
||||
'login.oidcSignIn': 'Accedi con {name}',
|
||||
'login.oidcOnly': 'L\'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.',
|
||||
'login.oidcLoggedOut': 'Sei stato disconnesso. Accedi nuovamente tramite il tuo provider SSO.',
|
||||
'login.demoHint': 'Prova la demo — nessuna registrazione necessaria',
|
||||
'login.mfaTitle': 'Autenticazione a due fattori',
|
||||
'login.mfaSubtitle': 'Inserisci il codice a 6 cifre dalla tua app authenticator.',
|
||||
|
||||
@@ -369,6 +369,7 @@ const nl: Record<string, string> = {
|
||||
'login.demoFailed': 'Demo-login mislukt',
|
||||
'login.oidcSignIn': 'Inloggen met {name}',
|
||||
'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.',
|
||||
'login.oidcLoggedOut': 'Je bent uitgelogd. Log opnieuw in via je SSO-provider.',
|
||||
'login.demoHint': 'Probeer de demo — geen registratie nodig',
|
||||
|
||||
// Register
|
||||
|
||||
@@ -329,6 +329,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'login.demoFailed': 'Nie udało się zalogować do wersji demonstracyjnej',
|
||||
'login.oidcSignIn': 'Zaloguj się z {name}',
|
||||
'login.oidcOnly': 'Uwierzytelnianie hasłem jest wyłączone. Zaloguj się za pomocą swojego dostawcy SSO.',
|
||||
'login.oidcLoggedOut': 'Zostałeś wylogowany. Zaloguj się ponownie za pomocą swojego dostawcy SSO.',
|
||||
'login.demoHint': 'Wypróbuj demo — nie wymaga rejestracji',
|
||||
'login.mfaTitle': 'Uwierzytelnianie dwuskładnikowe',
|
||||
'login.mfaSubtitle': 'Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.',
|
||||
|
||||
@@ -369,6 +369,7 @@ const ru: Record<string, string> = {
|
||||
'login.demoFailed': 'Ошибка демо-входа',
|
||||
'login.oidcSignIn': 'Войти через {name}',
|
||||
'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.',
|
||||
'login.oidcLoggedOut': 'Вы вышли из системы. Войдите снова через вашего провайдера SSO.',
|
||||
'login.demoHint': 'Попробуйте демо — регистрация не требуется',
|
||||
|
||||
// Register
|
||||
|
||||
@@ -369,6 +369,7 @@ const zh: Record<string, string> = {
|
||||
'login.demoFailed': '演示登录失败',
|
||||
'login.oidcSignIn': '通过 {name} 登录',
|
||||
'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',
|
||||
'login.oidcLoggedOut': '您已退出登录。请重新通过 SSO 提供商登录。',
|
||||
'login.demoHint': '试用演示——无需注册',
|
||||
|
||||
// Register
|
||||
|
||||
@@ -353,6 +353,7 @@ const zhTw: Record<string, string> = {
|
||||
'login.demoFailed': '演示登入失敗',
|
||||
'login.oidcSignIn': '透過 {name} 登入',
|
||||
'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。',
|
||||
'login.oidcLoggedOut': '您已登出。請重新透過 SSO 提供商登入。',
|
||||
'login.demoHint': '試用演示——無需註冊',
|
||||
|
||||
// Register
|
||||
|
||||
@@ -1551,7 +1551,7 @@ docker run -d --name trek \\
|
||||
await adminApi.rotateJwtSecret()
|
||||
setShowRotateJwtModal(false)
|
||||
logout()
|
||||
navigate('/login')
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
setRotatingJwt(false)
|
||||
|
||||
@@ -480,15 +480,13 @@ export default function AtlasPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Match feature by ISO code OR region name
|
||||
// Match feature by ISO code OR region name (native or English)
|
||||
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
|
||||
}
|
||||
const nameEn = (f.properties?.name_en || '').toLowerCase()
|
||||
if (nameEn && visitedRegionNames.has(nameEn)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -535,15 +533,16 @@ export default function AtlasPage(): React.ReactElement {
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
const regionName = feature?.properties?.name || ''
|
||||
const regionNameEn = feature?.properties?.name_en || ''
|
||||
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
|
||||
const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || regionPlaceCounts[regionNameEn.toLowerCase()] || 0
|
||||
layer.on('click', () => {
|
||||
if (!countryA2) return
|
||||
if (visited) {
|
||||
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
|
||||
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase())
|
||||
if (regionEntry?.manuallyMarked) {
|
||||
setConfirmActionRef.current({
|
||||
type: 'unmark-region',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
@@ -29,10 +29,13 @@ export default function LoginPage(): React.ReactElement {
|
||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
||||
const [inviteToken, setInviteToken] = useState<string>('')
|
||||
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
||||
const exchangeInitiated = useRef(false)
|
||||
|
||||
const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore()
|
||||
const { setLanguageLocal } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect
|
||||
|
||||
const redirectTarget = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -63,11 +66,13 @@ export default function LoginPage(): React.ReactElement {
|
||||
}
|
||||
|
||||
if (oidcCode) {
|
||||
if (exchangeInitiated.current) return
|
||||
exchangeInitiated.current = true
|
||||
setIsLoading(true)
|
||||
window.history.replaceState({}, '', '/login')
|
||||
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then(async data => {
|
||||
window.history.replaceState({}, '', '/login')
|
||||
if (data.token) {
|
||||
await loadUser()
|
||||
navigate('/dashboard', { replace: true })
|
||||
@@ -75,7 +80,10 @@ export default function LoginPage(): React.ReactElement {
|
||||
setError(data.error || 'OIDC login failed')
|
||||
}
|
||||
})
|
||||
.catch(() => setError('OIDC login failed'))
|
||||
.catch(() => {
|
||||
window.history.replaceState({}, '', '/login')
|
||||
setError('OIDC login failed')
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
return
|
||||
}
|
||||
@@ -96,12 +104,12 @@ export default function LoginPage(): React.ReactElement {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) {
|
||||
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [navigate, t])
|
||||
}, [navigate, t, noRedirect])
|
||||
|
||||
const handleDemoLogin = async (): Promise<void> => {
|
||||
setError('')
|
||||
@@ -527,7 +535,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
{oidcOnly ? (
|
||||
<>
|
||||
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>{t('login.title')}</h2>
|
||||
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{t('login.oidcOnly')}</p>
|
||||
<p style={{ margin: '0 0 24px', fontSize: 13.5, color: '#9ca3af' }}>{noRedirect ? t('login.oidcLoggedOut') : t('login.oidcOnly')}</p>
|
||||
{error && (
|
||||
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626', marginBottom: 16 }}>
|
||||
{error}
|
||||
|
||||
@@ -431,7 +431,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
setShowReservationModal(false)
|
||||
if (data.type === 'hotel') {
|
||||
|
||||
@@ -39,6 +39,7 @@ services:
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
@@ -29,6 +29,7 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
|
||||
DEMO_MODE=false # Demo mode - resets data hourly
|
||||
|
||||
# MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
# MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
|
||||
|
||||
# Initial admin account — only used on first boot when no users exist yet.
|
||||
# If both are set the admin account is created with these credentials.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "2.9.5",
|
||||
"version": "2.9.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "2.9.5",
|
||||
"version": "2.9.11",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"archiver": "^6.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "2.9.5",
|
||||
"version": "2.9.11",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
|
||||
@@ -18,7 +18,8 @@ interface McpSession {
|
||||
const sessions = new Map<string, McpSession>();
|
||||
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const MAX_SESSIONS_PER_USER = 5;
|
||||
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
||||
const MAX_SESSIONS_PER_USER = Number.isFinite(sessionParsed) && sessionParsed > 0 ? sessionParsed : 5;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const parsed = Number.parseInt(process.env.MCP_RATE_LIMIT ?? "");
|
||||
const RATE_LIMIT_MAX = Number.isFinite(parsed) && parsed > 0 ? parsed : 60; // requests per minute per user
|
||||
|
||||
@@ -168,6 +168,34 @@ export function getParticipants(assignmentId: string | number) {
|
||||
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
|
||||
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
||||
.run(placeTime ?? null, endTime ?? null, id);
|
||||
|
||||
// Auto-sort: reorder timed assignments chronologically within the day
|
||||
if (placeTime) {
|
||||
const assignment = db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id) as { day_id: number } | undefined;
|
||||
if (assignment) {
|
||||
const dayAssignments = db.prepare(`
|
||||
SELECT da.id, COALESCE(da.assignment_time, p.place_time) as effective_time
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
WHERE da.day_id = ?
|
||||
ORDER BY da.order_index ASC
|
||||
`).all(assignment.day_id) as { id: number; effective_time: string | null }[];
|
||||
|
||||
// Separate timed and untimed, sort timed by time
|
||||
const timed = dayAssignments.filter(a => a.effective_time).sort((a, b) => {
|
||||
const ta = a.effective_time!.includes(':') ? a.effective_time! : '99:99';
|
||||
const tb = b.effective_time!.includes(':') ? b.effective_time! : '99:99';
|
||||
return ta.localeCompare(tb);
|
||||
});
|
||||
const untimed = dayAssignments.filter(a => !a.effective_time);
|
||||
|
||||
// Interleave: timed in chronological order, untimed keep relative position
|
||||
const reordered = [...timed, ...untimed];
|
||||
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?');
|
||||
reordered.forEach((a, i) => update.run(i, a.id));
|
||||
}
|
||||
}
|
||||
|
||||
return getAssignmentWithPlace(Number(id));
|
||||
}
|
||||
|
||||
|
||||
@@ -421,7 +421,7 @@ async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInf
|
||||
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
||||
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
||||
}
|
||||
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
|
||||
const regionName = data.address?.state || data.address?.province || data.address?.region || data.address?.county || data.address?.city || null;
|
||||
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
||||
const info: RegionInfo = {
|
||||
country_code: countryCode,
|
||||
|
||||
@@ -234,8 +234,8 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title || null,
|
||||
reservation_time !== undefined ? (reservation_time || null) : current.reservation_time,
|
||||
reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time,
|
||||
(type ?? current.type) === 'hotel' ? null : (reservation_time !== undefined ? (reservation_time || null) : current.reservation_time),
|
||||
(type ?? current.type) === 'hotel' ? null : (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time),
|
||||
location !== undefined ? (location || null) : current.location,
|
||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||
notes !== undefined ? (notes || null) : current.notes,
|
||||
|
||||
@@ -202,3 +202,184 @@ describe('Bucket list', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mark/unmark region', () => {
|
||||
it('ATLAS-009 — POST /region/:code/mark marks a region as visited', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('ATLAS-009 — POST /region/:code/mark without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ country_code: 'DE' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ATLAS-009 — POST /region/:code/mark without country_code returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('ATLAS-009 — marking a region also auto-marks the parent country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
const stats = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||
expect(codes).toContain('DE');
|
||||
});
|
||||
|
||||
it('ATLAS-009 — marking the same region twice is idempotent', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('ATLAS-010 — GET /regions returns marked regions grouped by country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-BY/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Bayern', country_code: 'DE' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('regions');
|
||||
const deRegions = res.body.regions['DE'] as any[];
|
||||
expect(deRegions).toBeDefined();
|
||||
const codes = deRegions.map((r: any) => r.code);
|
||||
expect(codes).toContain('DE-NW');
|
||||
expect(codes).toContain('DE-BY');
|
||||
});
|
||||
|
||||
it('ATLAS-011 — DELETE /region/:code/mark unmarks a region', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.success).toBe(true);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const deRegions = res.body.regions['DE'] as any[] | undefined;
|
||||
const codes = (deRegions || []).map((r: any) => r.code);
|
||||
expect(codes).not.toContain('DE-NW');
|
||||
});
|
||||
|
||||
it('ATLAS-011 — unmark last region in country also unmarks the parent country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
await request(app)
|
||||
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const stats = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||
expect(codes).not.toContain('DE');
|
||||
});
|
||||
|
||||
it('ATLAS-011 — unmark one region keeps country when another region remains', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-BY/mark')
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'Bayern', country_code: 'DE' });
|
||||
|
||||
await request(app)
|
||||
.delete('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const stats = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
const codes = (stats.body.countries as any[]).map((c: any) => c.code);
|
||||
expect(codes).toContain('DE');
|
||||
});
|
||||
|
||||
it('ATLAS-011 — regions are isolated between users', async () => {
|
||||
const { user: user1 } = createUser(testDb);
|
||||
const { user: user2 } = createUser(testDb);
|
||||
|
||||
await request(app)
|
||||
.post('/api/addons/atlas/region/DE-NW/mark')
|
||||
.set('Cookie', authCookie(user1.id))
|
||||
.send({ name: 'Nordrhein-Westfalen', country_code: 'DE' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/regions')
|
||||
.set('Cookie', authCookie(user2.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const deRegions = res.body.regions['DE'] as any[] | undefined;
|
||||
expect(deRegions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,4 +58,5 @@
|
||||
<!-- Other -->
|
||||
<Config Name="DEMO_MODE" Target="DEMO_MODE" Default="false" Mode="" Description="Enable demo mode (resets all data hourly). Not intended for regular use." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||
<Config Name="MCP_RATE_LIMIT" Target="MCP_RATE_LIMIT" Default="60" Mode="" Description="Max MCP API requests per user per minute." Type="Variable" Display="advanced" Required="false" Mask="false">60</Config>
|
||||
<Config Name="MCP_MAX_SESSION_PER_USER" Target="MCP_MAX_SESSION_PER_USER" Default="5" Mode="" Description="Max concurrent MCP sessions per user." Type="Variable" Display="advanced" Required="false" Mask="false">5</Config>
|
||||
</Container>
|
||||
|
||||
Reference in New Issue
Block a user