Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
jubnl
2026-04-03 14:45:34 +02:00
20 changed files with 17163 additions and 16726 deletions

View File

@@ -1,5 +1,8 @@
name: Tests
permissions:
contents: read
on:
push:
branches: [main, dev]

View File

@@ -119,7 +119,7 @@ export default function GitHubPanel() {
return (
<div className="space-y-3">
{/* Support cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<a
href="https://ko-fi.com/mauriceboe"
target="_blank"
@@ -156,6 +156,24 @@ export default function GitHubPanel() {
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/nSdKaXgN"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
>
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#5865F215', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><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>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Discord</div>
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>Join the community</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
</div>
{/* Loading / Error / Releases */}

View File

@@ -232,9 +232,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button>
{appVersion && (
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, 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: 24, height: 24, 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="12" height="12" 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>
</div>
)}

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

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

View File

@@ -661,7 +661,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'atlas.statsTab': 'Statystyki',
'atlas.bucketTab': 'Lista marzeń',
'atlas.addBucket': 'Dodaj do listy marzeń',
'atlas.bucketNamePlaceholder': 'Miejsce lub cel podróży...',
'atlas.bucketNotesPlaceholder': 'Notatki (opcjonalnie)',
'atlas.bucketEmpty': 'Twoja lista marzeń jest pusta',
'atlas.bucketEmptyHint': 'Dodaj miejsca, które chcesz odwiedzić',
@@ -674,7 +673,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'atlas.nextTrip': 'Następna podróż',
'atlas.daysLeft': 'dni do wyjazdu',
'atlas.streak': 'Streak',
'atlas.year': 'rok',
'atlas.years': 'lata',
'atlas.yearInRow': 'rok z rzędu',
'atlas.yearsInRow': 'lat z rzędu',
@@ -1388,6 +1386,151 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'collab.polls.options': 'Opcje',
'collab.polls.delete': 'Usuń',
'collab.polls.closedSection': 'Zamknięte',
'common.import': 'Importuj',
'common.saved': 'Zapisano',
'trips.reminder': 'Przypomnienie',
'trips.reminderNone': 'Brak',
'trips.reminderDay': 'dzień',
'trips.reminderDays': 'dni',
'trips.reminderCustom': 'Niestandardowe',
'trips.reminderDaysBefore': 'dni przed wyjazdem',
'trips.reminderDisabledHint': 'Przypomnienia o podróżach są wyłączone.',
'dashboard.members': 'Towarzysze',
'dashboard.copyTrip': 'Kopiuj',
'dashboard.copySuffix': 'kopia',
'dashboard.toast.copied': 'Podróż skopiowana!',
'dashboard.toast.copyError': 'Nie udało się skopiować podróży',
'admin.notifications.title': 'Powiadomienia',
'admin.notifications.hint': 'Wybierz jeden kanał powiadomień.',
'admin.notifications.none': 'Wyłączone',
'admin.notifications.email': 'Email (SMTP)',
'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Zdarzenia powiadomień',
'admin.notifications.eventsHint': 'Wybierz zdarzenia wyzwalające powiadomienia.',
'admin.notifications.configureFirst': 'Najpierw skonfiguruj ustawienia SMTP lub webhook.',
'admin.notifications.save': 'Zapisz ustawienia powiadomień',
'admin.notifications.saved': 'Ustawienia powiadomień zapisane',
'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.',
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
'settings.notificationsActive': 'Aktywny kanał',
'settings.notificationsManagedByAdmin': 'Zdarzenia konfigurowane przez administratora.',
'settings.mustChangePassword': 'Musisz zmienić hasło przed kontynuowaniem.',
'login.setNewPassword': 'Ustaw nowe hasło',
'login.setNewPasswordHint': 'Musisz zmienić hasło.',
'atlas.searchCountry': 'Szukaj kraju...',
'trip.loadingPhotos': 'Ładowanie zdjęć...',
'places.importGoogleList': 'Lista Google',
'places.googleListHint': 'Wklej link do listy Google Maps.',
'places.googleListImported': 'Zaimportowano {count} miejsc',
'places.googleListError': 'Nie udało się zaimportować listy',
'places.viewDetails': 'Zobacz szczegóły',
'inspector.trackStats': 'Statystyki trasy',
'budget.exportCsv': 'Eksportuj CSV',
'budget.table.date': 'Data',
'memories.testFirst': 'Najpierw przetestuj połączenie',
'memories.linkAlbum': 'Połącz album',
'memories.selectAlbum': 'Wybierz album Immich',
'memories.noAlbums': 'Nie znaleziono albumów',
'memories.syncAlbum': 'Synchronizuj album',
'memories.unlinkAlbum': 'Odłącz album',
'memories.photos': 'zdjęcia',
'memories.error.loadAlbums': 'Nie udało się załadować albumów',
'memories.error.linkAlbum': 'Nie udało się połączyć albumu',
'memories.error.unlinkAlbum': 'Nie udało się odłączyć albumu',
'memories.error.syncAlbum': 'Nie udało się zsynchronizować albumu',
'memories.error.loadPhotos': 'Nie udało się załadować zdjęć',
'memories.error.addPhotos': 'Nie udało się dodać zdjęć',
'memories.error.removePhoto': 'Nie udało się usunąć zdjęcia',
'memories.error.toggleSharing': 'Nie udało się zaktualizować udostępniania',
'collab.chat.reply': 'Odpowiedz',
'admin.tabs.permissions': 'Uprawnienia',
'perm.title': 'Ustawienia uprawnień',
'perm.subtitle': 'Kontroluj uprawnienia w aplikacji',
'perm.saved': 'Ustawienia uprawnień zapisane',
'perm.resetDefaults': 'Przywróć domyślne',
'perm.customized': 'dostosowane',
'perm.level.admin': 'Tylko admin',
'perm.level.tripOwner': 'Właściciel podróży',
'perm.level.tripMember': 'Członkowie podróży',
'perm.level.everybody': 'Wszyscy',
'perm.cat.trip': 'Zarządzanie podróżami',
'perm.cat.members': 'Zarządzanie członkami',
'perm.cat.files': 'Pliki',
'perm.cat.content': 'Treść i harmonogram',
'perm.cat.extras': 'Budżet, pakowanie i współpraca',
'perm.action.trip_create': 'Tworzenie podróży',
'perm.action.trip_edit': 'Edytowanie podróży',
'perm.action.trip_delete': 'Usuwanie podróży',
'perm.action.trip_archive': 'Archiwizacja podróży',
'perm.action.trip_cover_upload': 'Przesyłanie okładki',
'perm.action.member_manage': 'Zarządzanie członkami',
'perm.action.file_upload': 'Przesyłanie plików',
'perm.action.file_edit': 'Edytowanie plików',
'perm.action.file_delete': 'Usuwanie plików',
'perm.action.place_edit': 'Zarządzanie miejscami',
'perm.action.day_edit': 'Edytowanie dni',
'perm.action.reservation_edit': 'Zarządzanie rezerwacjami',
'perm.action.budget_edit': 'Zarządzanie budżetem',
'perm.action.packing_edit': 'Zarządzanie pakowaniem',
'perm.action.collab_edit': 'Współpraca',
'perm.action.share_manage': 'Zarządzanie udostępnianiem',
'perm.actionHint.trip_create': 'Kto może tworzyć nowe podróże',
'perm.actionHint.trip_edit': 'Kto może edytować szczegóły podróży',
'perm.actionHint.trip_delete': 'Kto może usunąć podróż',
'perm.actionHint.trip_archive': 'Kto może archiwizować podróż',
'perm.actionHint.trip_cover_upload': 'Kto może zmieniać okładkę',
'perm.actionHint.member_manage': 'Kto może zapraszać lub usuwać członków',
'perm.actionHint.file_upload': 'Kto może przesyłać pliki',
'perm.actionHint.file_edit': 'Kto może edytować pliki',
'perm.actionHint.file_delete': 'Kto może usuwać pliki',
'perm.actionHint.place_edit': 'Kto może zarządzać miejscami',
'perm.actionHint.day_edit': 'Kto może edytować dni i przypisania',
'perm.actionHint.reservation_edit': 'Kto może zarządzać rezerwacjami',
'perm.actionHint.budget_edit': 'Kto może zarządzać budżetem',
'perm.actionHint.packing_edit': 'Kto może zarządzać pakowaniem',
'perm.actionHint.collab_edit': 'Kto może korzystać ze współpracy',
'perm.actionHint.share_manage': 'Kto może zarządzać linkami',
'undo.button': 'Cofnij',
'undo.tooltip': 'Cofnij: {action}',
'undo.assignPlace': 'Miejsce przypisane do dnia',
'undo.removeAssignment': 'Miejsce usunięte z dnia',
'undo.reorder': 'Kolejność zmieniona',
'undo.optimize': 'Trasa zoptymalizowana',
'undo.deletePlace': 'Miejsce usunięte',
'undo.moveDay': 'Miejsce przeniesione',
'undo.lock': 'Blokada przełączona',
'undo.importGpx': 'Import GPX',
'undo.importGoogleList': 'Import Google Maps',
'undo.addPlace': 'Miejsce dodane',
'undo.done': 'Cofnięto: {action}',
'notifications.title': 'Powiadomienia',
'notifications.markAllRead': 'Oznacz wszystkie jako przeczytane',
'notifications.deleteAll': 'Usuń wszystkie',
'notifications.showAll': 'Pokaż wszystkie',
'notifications.empty': 'Brak powiadomień',
'notifications.emptyDescription': "You're all caught up!",
'notifications.all': 'Wszystkie',
'notifications.unreadOnly': 'Nieprzeczytane',
'notifications.markRead': 'Oznacz jako przeczytane',
'notifications.markUnread': 'Oznacz jako nieprzeczytane',
'notifications.delete': 'Usuń',
'notifications.system': 'System',
'notifications.test.title': 'Testowe powiadomienie od {actor}',
'notifications.test.text': 'To jest powiadomienie testowe.',
'notifications.test.booleanTitle': '{actor} prosi o akceptację',
'notifications.test.booleanText': 'Testowe powiadomienie z wyborem.',
'notifications.test.accept': 'Zatwierdź',
'notifications.test.decline': 'Odrzuć',
'notifications.test.navigateTitle': 'Sprawdź coś',
'notifications.test.navigateText': 'Testowe powiadomienie nawigacyjne.',
'notifications.test.goThere': 'Przejdź tam',
'notifications.test.adminTitle': 'Komunikat administracyjny',
'notifications.test.adminText': '{actor} wysłał testowe powiadomienie.',
'notifications.test.tripTitle': '{actor} opublikował w Twojej podróży',
'notifications.test.tripText': 'Testowe powiadomienie dla podróży "{trip}".',
}
export default pl

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,9 @@ interface AuthState {
demoLogin: () => Promise<AuthResponse>
}
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
let authSequence = 0
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
@@ -61,6 +64,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
tripRemindersEnabled: false,
login: async (email: string, password: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
@@ -84,6 +88,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
completeMfaLogin: async (mfaToken: string, code: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
@@ -103,6 +108,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
register: async (username: string, email: string, password: string, invite_token?: string) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password, invite_token })
@@ -138,10 +144,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
loadUser: async (opts?: { silent?: boolean }) => {
const seq = authSequence
const silent = !!opts?.silent
if (!silent) set({ isLoading: true })
try {
const data = await authApi.me()
if (seq !== authSequence) return // stale response — a login/register happened meanwhile
set({
user: data.user,
isAuthenticated: true,
@@ -149,6 +157,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
})
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
// Only clear auth state on 401 (invalid/expired token), not on network errors
const isAuthError = err && typeof err === 'object' && 'response' in err &&
(err as { response?: { status?: number } }).response?.status === 401
@@ -219,6 +228,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
demoLogin: async () => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.demoLogin()

View File

@@ -3,10 +3,10 @@ import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { canAccessTrip } from '../db/database';
import { db, canAccessTrip } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types';
import { AuthRequest, Trip } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import { checkPermission } from '../services/permissions';
import {
@@ -26,6 +26,7 @@ import {
verifyTripAccess,
NotFoundError,
ValidationError,
TRIP_SELECT,
} from '../services/tripService';
const router = express.Router();

View File

@@ -7,7 +7,7 @@ export function cookieOptions(clear = false) {
return {
httpOnly: true,
secure,
sameSite: 'strict' as const,
sameSite: 'lax' as const,
path: '/',
...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry
};

View File

@@ -6,7 +6,7 @@ import { Trip, User } from '../types';
export const MS_PER_DAY = 86400000;
export const MAX_TRIP_DAYS = 365;
const TRIP_SELECT = `
export const TRIP_SELECT = `
SELECT t.*,
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,

View File

@@ -11,8 +11,8 @@ describe('cookieOptions', () => {
expect(cookieOptions()).toHaveProperty('httpOnly', true);
});
it('always sets sameSite: strict', () => {
expect(cookieOptions()).toHaveProperty('sameSite', 'strict');
it('always sets sameSite: lax', () => {
expect(cookieOptions()).toHaveProperty('sameSite', 'lax');
});
it('always sets path: /', () => {