From f786947941ec2b078f651c7ea8661ccff0aedfe3 Mon Sep 17 00:00:00 2001 From: le king fu Date: Wed, 8 Apr 2026 13:12:59 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20Logto=20auth=20crash=20on=20we?= =?UTF-8?q?b=20=E2=80=94=20remove=20illegal=20cookie=20set=20in=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The (app)/layout.tsx was calling cookieStore.set() which is forbidden in Server Components under Next.js 16 (only allowed in Server Actions and Route Handlers). This caused a 500 error immediately after Logto login. Also includes: mobile sync client improvements, i18n updates, web API rate limiting, Bearer token support for mobile clients, and Dockerfile optimizations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/eas-build/SKILL.md | 28 ++ app.json | 8 +- app/(tabs)/settings.tsx | 104 ++++- app/_layout.tsx | 46 +- package-lock.json | 172 +++++++- package.json | 5 +- src/i18n/en.json | 12 +- src/i18n/fr.json | 12 +- src/lib/authToken.ts | 23 + src/lib/logtoConfig.ts | 11 + src/services/syncClient.ts | 250 ++++++++--- tsconfig.json | 3 + web/Dockerfile | 18 +- web/package-lock.json | 108 ++++- web/package.json | 3 + web/src/app/(app)/layout.tsx | 4 +- web/src/app/(app)/lists/[id]/page.tsx | 12 +- web/src/app/(app)/page.tsx | 18 +- web/src/app/api/debug/route.ts | 40 ++ web/src/app/api/lists/[id]/route.ts | 5 + web/src/app/api/lists/[id]/tasks/route.ts | 3 + web/src/app/api/lists/reorder/route.ts | 3 + web/src/app/api/lists/route.ts | 5 + web/src/app/api/logto/callback/route.ts | 11 +- web/src/app/api/logto/sign-in/route.ts | 4 +- web/src/app/api/logto/sign-out/route.ts | 10 +- web/src/app/api/sync/route.ts | 5 + web/src/app/api/tags/[id]/route.ts | 5 + web/src/app/api/tags/route.ts | 5 + web/src/app/api/tasks/[id]/route.ts | 5 + web/src/app/api/tasks/[id]/subtasks/route.ts | 3 + .../app/api/tasks/[id]/tags/[tagId]/route.ts | 3 + web/src/app/api/tasks/[id]/tags/route.ts | 3 + web/src/app/api/tasks/reorder/route.ts | 3 + web/src/app/api/tasks/route.ts | 3 + web/src/app/api/ws-ticket/route.ts | 3 + web/src/app/auth/page.tsx | 15 +- web/src/app/layout.tsx | 5 +- web/src/components/AuthContext.tsx | 27 ++ web/src/components/FilterBar.tsx | 32 +- web/src/components/Header.tsx | 6 +- web/src/components/I18nProvider.tsx | 7 + web/src/components/Sidebar.tsx | 18 +- web/src/components/TaskForm.tsx | 36 +- web/src/components/TaskItem.tsx | 70 ++- web/src/components/TaskList.tsx | 5 +- web/src/components/ThemeToggle.tsx | 5 +- web/src/components/WelcomeMessage.tsx | 16 + web/src/db/migrations/0000_tidy_mandarin.sql | 54 +++ .../0001_change_user_id_to_text.sql | 3 + web/src/db/migrations/meta/0000_snapshot.json | 410 ++++++++++++++++++ web/src/db/migrations/meta/_journal.json | 20 + web/src/db/schema.ts | 6 +- web/src/i18n/en.json | 73 ++++ web/src/i18n/fr.json | 73 ++++ web/src/i18n/index.ts | 26 ++ web/src/lib/api.ts | 37 ++ web/src/lib/auth.ts | 21 +- web/src/lib/rateLimit.ts | 56 +++ web/src/middleware.ts | 12 +- 60 files changed, 1771 insertions(+), 218 deletions(-) create mode 100644 .claude/skills/eas-build/SKILL.md create mode 100644 src/lib/authToken.ts create mode 100644 src/lib/logtoConfig.ts create mode 100644 web/src/app/api/debug/route.ts create mode 100644 web/src/components/AuthContext.tsx create mode 100644 web/src/components/I18nProvider.tsx create mode 100644 web/src/components/WelcomeMessage.tsx create mode 100644 web/src/db/migrations/0000_tidy_mandarin.sql create mode 100644 web/src/db/migrations/0001_change_user_id_to_text.sql create mode 100644 web/src/db/migrations/meta/0000_snapshot.json create mode 100644 web/src/db/migrations/meta/_journal.json create mode 100644 web/src/i18n/en.json create mode 100644 web/src/i18n/fr.json create mode 100644 web/src/i18n/index.ts create mode 100644 web/src/lib/rateLimit.ts diff --git a/.claude/skills/eas-build/SKILL.md b/.claude/skills/eas-build/SKILL.md new file mode 100644 index 0000000..bc504a1 --- /dev/null +++ b/.claude/skills/eas-build/SKILL.md @@ -0,0 +1,28 @@ +--- +name: eas-build +description: Build APK via EAS and create Forgejo release +user-invocable: true +--- + +# /eas-build — Build APK Simpl-Liste + +## Context injection + +1. Lire `app.json` → `expo.android.versionCode` et `expo.version` +2. Lire `eas.json` → profils disponibles + +## Workflow + +1. Lire le `versionCode` actuel dans `app.json` +2. Incrementer `versionCode` (+1) — doit etre strictement superieur +3. Si demande par l'utilisateur : bumper `version` dans `app.json` + `package.json` +4. Commit : `chore: bump versionCode to ` +5. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive` +6. Quand le build est termine : creer une release Forgejo avec le lien APK + +## Regles + +- `versionCode` doit etre **strictement superieur** a la valeur precedente +- `autoIncrement` dans eas.json ne s'applique qu'au profil `production`, pas `preview` +- Toujours utiliser `npx --yes eas-cli` (pas d'install globale) +- Ne JAMAIS `git push --tags` — push les tags un par un si necessaire diff --git a/app.json b/app.json index eae9611..357cf3c 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Simpl-Liste", "slug": "simpl-liste", - "version": "1.4.0", + "version": "1.5.1", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "simplliste", @@ -24,7 +24,7 @@ "backgroundColor": "#FFF8F0" }, "edgeToEdgeEnabled": true, - "versionCode": 7 + "versionCode": 10 }, "plugins": [ "expo-router", @@ -74,7 +74,9 @@ } ] } - ] + ], + "expo-secure-store", + "expo-web-browser" ], "experiments": { "typedRoutes": true diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 35591ad..83c0f85 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -5,12 +5,14 @@ import { useTranslation } from 'react-i18next'; import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw, Cloud, LogIn, LogOut } from 'lucide-react-native'; import Constants from 'expo-constants'; +import { useLogto } from '@logto/rn'; import { colors } from '@/src/theme/colors'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { redirectUri, postSignOutRedirectUri } from '@/src/lib/logtoConfig'; import { getAllTags, createTag, updateTag, deleteTag } from '@/src/db/repository/tags'; import { initCalendar } from '@/src/services/calendar'; import { syncWidgetData } from '@/src/services/widgetSync'; -import { fullSync } from '@/src/services/syncClient'; +import { fullSync, initialMerge, initialReset } from '@/src/services/syncClient'; import i18n from '@/src/i18n'; type ThemeMode = 'light' | 'dark' | 'system'; @@ -39,6 +41,7 @@ export default function SettingsScreen() { const [tagColor, setTagColor] = useState(TAG_COLORS[0]); const [checkingUpdate, setCheckingUpdate] = useState(false); const [isSyncing, setIsSyncing] = useState(false); + const { signIn: logtoSignIn, signOut: logtoSignOut, getIdTokenClaims, isAuthenticated } = useLogto(); const loadTags = useCallback(async () => { const result = await getAllTags(); @@ -99,12 +102,52 @@ export default function SettingsScreen() { ]); }; - const handleSignIn = () => { - // Placeholder: actual Logto OAuth flow would go here - // For now, set a placeholder userId to test the sync UI - const placeholderId = 'user-placeholder'; - setUserId(placeholderId); - setSyncEnabled(true); + const handleSignIn = async () => { + try { + await logtoSignIn({ redirectUri }); + const claims = await getIdTokenClaims(); + setUserId(claims.sub); + setSyncEnabled(true); + + // First sync: show merge/reset choice + Alert.alert( + t('sync.firstSyncTitle'), + t('sync.firstSyncMessage'), + [ + { + text: t('sync.mergeLocal'), + onPress: async () => { + setIsSyncing(true); + try { + await initialMerge(); + Alert.alert(t('sync.firstSyncTitle'), t('sync.mergeDone')); + } catch { + Alert.alert(t('sync.syncError')); + } finally { + setIsSyncing(false); + } + }, + }, + { + text: t('sync.resetFromServer'), + style: 'destructive', + onPress: async () => { + setIsSyncing(true); + try { + await initialReset(); + Alert.alert(t('sync.firstSyncTitle'), t('sync.resetDone')); + } catch { + Alert.alert(t('sync.syncError')); + } finally { + setIsSyncing(false); + } + }, + }, + ], + ); + } catch (err) { + console.warn('[auth] sign-in error:', err); + } }; const handleSignOut = () => { @@ -113,7 +156,12 @@ export default function SettingsScreen() { { text: t('sync.signOut'), style: 'destructive', - onPress: () => { + onPress: async () => { + try { + await logtoSignOut(postSignOutRedirectUri); + } catch { + // Sign-out may fail if session expired, that's OK + } setSyncEnabled(false); setUserId(null); setLastSyncAt(null); @@ -123,6 +171,46 @@ export default function SettingsScreen() { }; const handleSyncNow = async () => { + // If never synced before, show first-sync choice + if (!lastSyncAt) { + Alert.alert( + t('sync.firstSyncTitle'), + t('sync.firstSyncMessage'), + [ + { + text: t('sync.mergeLocal'), + onPress: async () => { + setIsSyncing(true); + try { + await initialMerge(); + Alert.alert(t('sync.firstSyncTitle'), t('sync.mergeDone')); + } catch { + Alert.alert(t('sync.syncError')); + } finally { + setIsSyncing(false); + } + }, + }, + { + text: t('sync.resetFromServer'), + style: 'destructive', + onPress: async () => { + setIsSyncing(true); + try { + await initialReset(); + Alert.alert(t('sync.firstSyncTitle'), t('sync.resetDone')); + } catch { + Alert.alert(t('sync.syncError')); + } finally { + setIsSyncing(false); + } + }, + }, + ], + ); + return; + } + setIsSyncing(true); try { await fullSync(); diff --git a/app/_layout.tsx b/app/_layout.tsx index 7338032..c2d10ad 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,7 @@ import { useMigrations } from 'drizzle-orm/expo-sqlite/migrator'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { KeyboardProvider } from 'react-native-keyboard-controller'; +import { LogtoProvider, useLogto } from '@logto/rn'; import { db } from '@/src/db/client'; import migrations from '@/src/db/migrations/migrations'; import { ensureInbox } from '@/src/db/repository/lists'; @@ -15,6 +16,8 @@ import { useSettingsStore } from '@/src/stores/useSettingsStore'; import { initNotifications } from '@/src/services/notifications'; import { syncWidgetData } from '@/src/services/widgetSync'; import { fullSync, cleanOutbox } from '@/src/services/syncClient'; +import { logtoConfig } from '@/src/lib/logtoConfig'; +import { setTokenGetter, clearTokenGetter } from '@/src/lib/authToken'; import '@/src/i18n'; import '@/src/global.css'; @@ -56,12 +59,6 @@ export default function RootLayout() { const { success: migrationsReady, error: migrationError } = useMigrations(db, migrations); - const systemScheme = useColorScheme(); - const theme = useSettingsStore((s) => s.theme); - const syncEnabled = useSettingsStore((s) => s.syncEnabled); - const effectiveScheme = theme === 'system' ? systemScheme : theme; - const appState = useRef(AppState.currentState); - useEffect(() => { if (fontError) throw fontError; if (migrationError) throw migrationError; @@ -77,9 +74,38 @@ export default function RootLayout() { } }, [fontsLoaded, migrationsReady]); + if (!fontsLoaded || !migrationsReady) { + return null; + } + + return ( + + + + ); +} + +function AppContent() { + const systemScheme = useColorScheme(); + const theme = useSettingsStore((s) => s.theme); + const syncEnabled = useSettingsStore((s) => s.syncEnabled); + const effectiveScheme = theme === 'system' ? systemScheme : theme; + const appState = useRef(AppState.currentState); + const { getAccessToken, isAuthenticated } = useLogto(); + + // Register the token getter for syncClient when authenticated + useEffect(() => { + if (isAuthenticated && syncEnabled) { + setTokenGetter(getAccessToken); + } else { + clearTokenGetter(); + } + return () => clearTokenGetter(); + }, [isAuthenticated, syncEnabled, getAccessToken]); + // Sync polling: run on launch, every 2 min, and on return from background useEffect(() => { - if (!syncEnabled || !migrationsReady) return; + if (!syncEnabled) return; // Initial sync fullSync().then(() => cleanOutbox()).catch(() => {}); @@ -101,11 +127,7 @@ export default function RootLayout() { clearInterval(interval); subscription.remove(); }; - }, [syncEnabled, migrationsReady]); - - if (!fontsLoaded || !migrationsReady) { - return null; - } + }, [syncEnabled]); return ( diff --git a/package-lock.json b/package-lock.json index e433124..16cf09d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,24 @@ { "name": "simpl-liste", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simpl-liste", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@expo-google-fonts/inter": "^0.4.2", "@expo/ngrok": "^4.1.3", "@expo/vector-icons": "^15.0.3", + "@logto/rn": "^1.1.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/native": "^7.1.8", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "expo": "~54.0.33", + "expo-auth-session": "~7.0.10", "expo-calendar": "~15.0.8", "expo-constants": "~18.0.13", "expo-crypto": "~15.0.8", @@ -28,6 +30,7 @@ "expo-localization": "~17.0.8", "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-sqlite": "~16.0.10", @@ -2915,6 +2918,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@logto/client": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@logto/client/-/client-3.1.2.tgz", + "integrity": "sha512-HRu6qO4QYQn5ckO5wHi8On/C4Nsp/5qYDbf6zrFjymSVlJlXmDft+OW/AQ9jdPl1kAgZJIlQzjvpM9YFy/7c6Q==", + "license": "MIT", + "dependencies": { + "@logto/js": "^5.1.1", + "@silverhand/essentials": "^2.9.2", + "camelcase-keys": "^9.1.3", + "jose": "^5.2.2" + } + }, + "node_modules/@logto/js": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@logto/js/-/js-5.1.1.tgz", + "integrity": "sha512-HMK9AFQ+mzJQ2WuKrJJ2apjoTjGbbu45vIhAl31t0JbSi++3IcPp3/oIhsS+VJ7AOs8x5P+fjWJO2AIwhQe3Vg==", + "license": "MIT", + "dependencies": { + "@silverhand/essentials": "^2.9.2", + "camelcase-keys": "^9.1.3" + } + }, + "node_modules/@logto/rn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@logto/rn/-/rn-1.1.0.tgz", + "integrity": "sha512-GcB6gGrjBASrTy4FsyJWCgYHaCjl2Tl/6CL+OZfU9Vro7meyfrW2+bHBOi7aKeXl+tLNqUybHoFcv+sVvUObxw==", + "license": "MIT", + "dependencies": { + "@logto/client": "3.1.2", + "@logto/js": "5.1.1", + "crypto-es": "^2.1.0", + "js-base64": "^3.7.7" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": ">=1.23.1 <3", + "expo-crypto": ">=14.0.2 <16", + "expo-secure-store": ">=14.0.1 <16", + "expo-web-browser": ">=14.0.2 <16", + "react-native": ">=0.76.0 <1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3528,6 +3572,16 @@ "nanoid": "^3.3.11" } }, + "node_modules/@silverhand/essentials": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz", + "integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==", + "license": "MIT", + "engines": { + "node": ">=18.12.0", + "pnpm": "^10.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -4498,6 +4552,60 @@ "node": ">= 6" } }, + "node_modules/camelcase-keys": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz", + "integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==", + "license": "MIT", + "dependencies": { + "camelcase": "^8.0.0", + "map-obj": "5.0.0", + "quick-lru": "^6.1.1", + "type-fest": "^4.3.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001770", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", @@ -4884,6 +4992,12 @@ "node": ">= 8" } }, + "node_modules/crypto-es": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/crypto-es/-/crypto-es-2.1.0.tgz", + "integrity": "sha512-C5Dbuv4QTPGuloy5c5Vv/FZHtmK+lobLAypFfuRaBbwCsk3qbCWWESCH3MUcBsrgXloRNMrzwUAiPg4U6+IaKA==", + "license": "MIT" + }, "node_modules/crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -5690,6 +5804,24 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz", + "integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==", + "license": "MIT", + "dependencies": { + "expo-application": "~7.0.8", + "expo-constants": "~18.0.11", + "expo-crypto": "~15.0.8", + "expo-linking": "~8.0.10", + "expo-web-browser": "~15.0.10", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-calendar": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-calendar/-/expo-calendar-15.0.8.tgz", @@ -6107,6 +6239,15 @@ "node": ">=10" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -7527,6 +7668,21 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8067,6 +8223,18 @@ "tmpl": "1.0.5" } }, + "node_modules/map-obj": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz", + "integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", diff --git a/package.json b/package.json index d34cece..0dfe762 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simpl-liste", "main": "index.js", - "version": "1.4.0", + "version": "1.5.1", "scripts": { "start": "expo start", "android": "expo start --android", @@ -12,12 +12,14 @@ "@expo-google-fonts/inter": "^0.4.2", "@expo/ngrok": "^4.1.3", "@expo/vector-icons": "^15.0.3", + "@logto/rn": "^1.1.0", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/datetimepicker": "8.4.4", "@react-navigation/native": "^7.1.8", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "expo": "~54.0.33", + "expo-auth-session": "~7.0.10", "expo-calendar": "~15.0.8", "expo-constants": "~18.0.13", "expo-crypto": "~15.0.8", @@ -29,6 +31,7 @@ "expo-localization": "~17.0.8", "expo-notifications": "~0.32.16", "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", "expo-sqlite": "~16.0.10", diff --git a/src/i18n/en.json b/src/i18n/en.json index ac9ed13..2531f3f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -142,7 +142,17 @@ "never": "Never synced", "connectedAs": "Connected: {{userId}}", "syncEnabled": "Sync enabled", - "syncDescription": "Syncs your data across devices" + "syncDescription": "Syncs your data across devices", + "firstSyncTitle": "First sync", + "firstSyncMessage": "You have tasks on this device. What would you like to do?", + "mergeLocal": "Merge my tasks", + "mergeDescription": "Sends your local tasks to the server", + "resetFromServer": "Start from server", + "resetDescription": "Replaces local data with server data", + "merging": "Merging...", + "mergeDone": "Tasks merged successfully!", + "resetDone": "Data synced from server.", + "syncError": "Sync error" }, "widget": { "title": "Simpl-Liste", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 373234e..845c8ad 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -142,7 +142,17 @@ "never": "Jamais synchronisé", "connectedAs": "Connecté : {{userId}}", "syncEnabled": "Synchronisation activée", - "syncDescription": "Synchronise vos données entre appareils" + "syncDescription": "Synchronise vos données entre appareils", + "firstSyncTitle": "Première synchronisation", + "firstSyncMessage": "Vous avez des tâches sur cet appareil. Que voulez-vous faire ?", + "mergeLocal": "Fusionner mes tâches", + "mergeDescription": "Envoie vos tâches locales vers le serveur", + "resetFromServer": "Repartir du serveur", + "resetDescription": "Remplace les données locales par celles du serveur", + "merging": "Fusion en cours...", + "mergeDone": "Tâches fusionnées avec succès !", + "resetDone": "Données synchronisées depuis le serveur.", + "syncError": "Erreur de synchronisation" }, "widget": { "title": "Simpl-Liste", diff --git a/src/lib/authToken.ts b/src/lib/authToken.ts new file mode 100644 index 0000000..dea06d6 --- /dev/null +++ b/src/lib/authToken.ts @@ -0,0 +1,23 @@ +// Holds a reference to the getAccessToken function from @logto/rn. +// Set from the React tree (via LogtoProvider/useLogto), used by syncClient. + +type TokenGetter = () => Promise; + +let _getAccessToken: TokenGetter | null = null; + +export function setTokenGetter(getter: TokenGetter): void { + _getAccessToken = getter; +} + +export function clearTokenGetter(): void { + _getAccessToken = null; +} + +export async function getAccessToken(): Promise { + if (!_getAccessToken) return null; + try { + return await _getAccessToken(); + } catch { + return null; + } +} diff --git a/src/lib/logtoConfig.ts b/src/lib/logtoConfig.ts new file mode 100644 index 0000000..4623954 --- /dev/null +++ b/src/lib/logtoConfig.ts @@ -0,0 +1,11 @@ +import type { LogtoNativeConfig } from '@logto/rn'; + +export const logtoConfig: LogtoNativeConfig = { + endpoint: 'https://auth.lacompagniemaximus.com', + appId: 'sl-mobile-native', + scopes: ['openid', 'profile', 'email'], +}; + +// Redirect URI uses the app scheme defined in app.json +export const redirectUri = 'simplliste://callback'; +export const postSignOutRedirectUri = 'simplliste://'; diff --git a/src/services/syncClient.ts b/src/services/syncClient.ts index 90a16d7..040dcaa 100644 --- a/src/services/syncClient.ts +++ b/src/services/syncClient.ts @@ -2,16 +2,18 @@ import { eq, isNull, not } from 'drizzle-orm'; import { db } from '@/src/db/client'; import { syncOutbox, lists, tasks, tags, taskTags } from '@/src/db/schema'; import { useSettingsStore } from '@/src/stores/useSettingsStore'; +import { getAccessToken } from '@/src/lib/authToken'; +import { randomUUID } from '@/src/lib/uuid'; const SYNC_API_BASE = 'https://liste.lacompagniemaximus.com'; +const INBOX_ID = '00000000-0000-0000-0000-000000000001'; -interface SyncPushEntry { - id: string; - entity_type: string; - entity_id: string; - action: string; - payload: string; - created_at: string; +interface SyncOperation { + idempotencyKey: string; + entityType: 'list' | 'task' | 'tag' | 'taskTag'; + entityId: string; + action: 'create' | 'update' | 'delete'; + data?: Record; } interface SyncPullChange { @@ -27,21 +29,45 @@ interface SyncPullResponse { sync_token: string; } -function getAuthHeaders(): Record { - const { userId } = useSettingsStore.getState(); - if (!userId) return {}; - // Placeholder: in real implementation, JWT from Logto would be used +async function getAuthHeaders(): Promise> { + const token = await getAccessToken(); + if (!token) return {}; return { - 'Authorization': `Bearer ${userId}`, + 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }; } +/** + * Send a batch of operations to the server sync endpoint. + */ +async function sendOperations(operations: SyncOperation[], headers: Record): Promise { + const batchSize = 50; + for (let i = 0; i < operations.length; i += batchSize) { + const batch = operations.slice(i, i + batchSize); + try { + const res = await fetch(`${SYNC_API_BASE}/api/sync`, { + method: 'POST', + headers, + body: JSON.stringify({ operations: batch }), + }); + if (!res.ok) { + console.warn(`[sync] push failed with status ${res.status}`); + return false; + } + } catch (err) { + console.warn('[sync] push error:', err); + return false; + } + } + return true; +} + /** * Push unsynced outbox entries to the server. */ export async function pushChanges(): Promise { - const headers = getAuthHeaders(); + const headers = await getAuthHeaders(); if (!headers['Authorization']) return; const unsynced = await db @@ -51,42 +77,25 @@ export async function pushChanges(): Promise { if (unsynced.length === 0) return; - // Send in batches of 50 - const batchSize = 50; - for (let i = 0; i < unsynced.length; i += batchSize) { - const batch = unsynced.slice(i, i + batchSize); - const entries: SyncPushEntry[] = batch.map((entry) => ({ - id: entry.id, - entity_type: entry.entityType, - entity_id: entry.entityId, - action: entry.action, - payload: entry.payload, - created_at: entry.createdAt, - })); + const operations: SyncOperation[] = unsynced.map((entry) => { + const data = JSON.parse(entry.payload); + return { + idempotencyKey: entry.id, + entityType: entry.entityType as SyncOperation['entityType'], + entityId: entry.entityId, + action: entry.action as SyncOperation['action'], + data, + }; + }); - try { - const res = await fetch(`${SYNC_API_BASE}/api/sync`, { - method: 'POST', - headers, - body: JSON.stringify({ changes: entries }), - }); - - if (!res.ok) { - console.warn(`[sync] push failed with status ${res.status}`); - return; // Stop pushing on error, retry later - } - - // Mark entries as synced - const now = new Date().toISOString(); - for (const entry of batch) { - await db - .update(syncOutbox) - .set({ syncedAt: now }) - .where(eq(syncOutbox.id, entry.id)); - } - } catch (err) { - console.warn('[sync] push error:', err); - return; // Network error, retry later + const ok = await sendOperations(operations, headers); + if (ok) { + const now = new Date().toISOString(); + for (const entry of unsynced) { + await db + .update(syncOutbox) + .set({ syncedAt: now }) + .where(eq(syncOutbox.id, entry.id)); } } } @@ -95,7 +104,7 @@ export async function pushChanges(): Promise { * Pull changes from the server since the last sync timestamp. */ export async function pullChanges(since: string): Promise { - const headers = getAuthHeaders(); + const headers = await getAuthHeaders(); if (!headers['Authorization']) return; try { @@ -261,6 +270,149 @@ export async function fullSync(): Promise { } } +/** + * First-time sync: merge all local data to server. + * Creates an Inbox on the server, remaps the local hardcoded Inbox ID, + * then pushes all lists, tasks, tags, and task-tag relations. + */ +export async function initialMerge(): Promise { + const headers = await getAuthHeaders(); + if (!headers['Authorization']) return; + + const operations: SyncOperation[] = []; + + // 1. Read all local data + const allLists = await db.select().from(lists); + const allTasks = await db.select().from(tasks); + const allTags = await db.select().from(tags); + const allTaskTags = await db.select().from(taskTags); + + // 2. First, create the Inbox on the server with a new UUID + const serverInboxId = randomUUID(); + const localInbox = allLists.find((l) => l.id === INBOX_ID); + + // Map old inbox ID → new inbox ID for task remapping + const idMap: Record = {}; + if (localInbox) { + idMap[INBOX_ID] = serverInboxId; + } + + // 3. Push lists + for (const list of allLists) { + const newId = idMap[list.id] || list.id; + operations.push({ + idempotencyKey: randomUUID(), + entityType: 'list', + entityId: newId, + action: 'create', + data: { + name: list.name, + color: list.color, + icon: list.icon, + position: list.position, + isInbox: list.isInbox, + }, + }); + } + + // 4. Push tasks (remap listId if it pointed to the old inbox) + for (const task of allTasks) { + const remappedListId = idMap[task.listId] || task.listId; + const remappedParentId = task.parentId || undefined; + operations.push({ + idempotencyKey: randomUUID(), + entityType: 'task', + entityId: task.id, + action: 'create', + data: { + title: task.title, + notes: task.notes, + completed: task.completed, + priority: task.priority, + dueDate: task.dueDate ? task.dueDate.toISOString() : undefined, + listId: remappedListId, + parentId: remappedParentId, + position: task.position, + recurrence: task.recurrence, + }, + }); + } + + // 5. Push tags + for (const tag of allTags) { + operations.push({ + idempotencyKey: randomUUID(), + entityType: 'tag', + entityId: tag.id, + action: 'create', + data: { + name: tag.name, + color: tag.color, + }, + }); + } + + // 6. Push task-tag relations + for (const tt of allTaskTags) { + operations.push({ + idempotencyKey: randomUUID(), + entityType: 'taskTag', + entityId: tt.taskId, + action: 'create', + data: { tagId: tt.tagId }, + }); + } + + // 7. Send to server + const ok = await sendOperations(operations, headers); + if (!ok) { + throw new Error('Failed to push local data to server'); + } + + // 8. Remap local Inbox ID to match the server + if (localInbox) { + // Update all tasks pointing to the old inbox + await db.update(tasks).set({ listId: serverInboxId }).where(eq(tasks.listId, INBOX_ID)); + // Delete old inbox and insert with new ID + await db.delete(lists).where(eq(lists.id, INBOX_ID)); + await db.insert(lists).values({ + ...localInbox, + id: serverInboxId, + updatedAt: new Date(), + }); + } + + // 9. Mark sync timestamp + useSettingsStore.getState().setLastSyncAt(new Date().toISOString()); +} + +/** + * First-time sync: discard local data and pull everything from server. + */ +export async function initialReset(): Promise { + const headers = await getAuthHeaders(); + if (!headers['Authorization']) return; + + // 1. Delete all local data + await db.delete(taskTags); + await db.delete(tasks); + await db.delete(tags); + await db.delete(lists); + await db.delete(syncOutbox); + + // 2. Pull everything from server + await pullChanges('1970-01-01T00:00:00.000Z'); + + // 3. Ensure we have a local inbox (the server may have created one) + const serverLists = await db.select().from(lists); + const hasInbox = serverLists.some((l) => l.isInbox); + if (!hasInbox) { + // Import ensureInbox dynamically to avoid circular deps + const { ensureInbox } = await import('@/src/db/repository/lists'); + await ensureInbox(); + } +} + /** * Clean up synced outbox entries to prevent unbounded growth. * Deletes all entries that have been successfully synced. diff --git a/tsconfig.json b/tsconfig.json index 2f35dd4..001f683 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,8 @@ ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts" + ], + "exclude": [ + "web" ] } \ No newline at end of file diff --git a/web/Dockerfile b/web/Dockerfile index 59d430c..6f1ccb6 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,10 +1,10 @@ FROM node:22-alpine AS base -# Dependencies +# Install production dependencies FROM base AS deps WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci --only=production +RUN npm ci --omit=dev # Build FROM base AS builder @@ -13,6 +13,9 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build +# Bundle custom server + ws into a single JS file +RUN npx esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist-server/server.js \ + --external:next --external:.next # Production FROM base AS runner @@ -22,15 +25,16 @@ ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +# Copy production node_modules (has full next package) +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ COPY --from=builder /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./server.ts +COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next +COPY --from=builder --chown=nextjs:nodejs /app/dist-server/server.js ./server.js USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -# Use custom server instead of default next start -CMD ["node", "server.ts"] +CMD ["node", "server.js"] diff --git a/web/package-lock.json b/web/package-lock.json index fd80da0..7b42fca 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,11 +12,14 @@ "@types/pg": "^8.20.0", "dotenv": "^17.4.1", "drizzle-orm": "^0.45.2", + "i18next": "^26.0.3", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.7.0", "next": "16.2.2", "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-i18next": "^17.0.2", "ws": "^8.20.0", "zod": "^4.3.6" }, @@ -238,6 +241,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -5143,6 +5155,55 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6847,6 +6908,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8237,7 +8325,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8372,6 +8460,24 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index af4f558..1c87792 100644 --- a/web/package.json +++ b/web/package.json @@ -13,11 +13,14 @@ "@types/pg": "^8.20.0", "dotenv": "^17.4.1", "drizzle-orm": "^0.45.2", + "i18next": "^26.0.3", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.7.0", "next": "16.2.2", "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-i18next": "^17.0.2", "ws": "^8.20.0", "zod": "^4.3.6" }, diff --git a/web/src/app/(app)/layout.tsx b/web/src/app/(app)/layout.tsx index c0f4ed1..59ebe55 100644 --- a/web/src/app/(app)/layout.tsx +++ b/web/src/app/(app)/layout.tsx @@ -15,7 +15,9 @@ export default async function AppLayout({ children: React.ReactNode; }) { const user = await getAuthenticatedUser(); - if (!user) redirect("/auth"); + if (!user) { + redirect("/auth"); + } const [lists, tags] = await Promise.all([ db diff --git a/web/src/app/(app)/lists/[id]/page.tsx b/web/src/app/(app)/lists/[id]/page.tsx index b89875a..fa5e9bb 100644 --- a/web/src/app/(app)/lists/[id]/page.tsx +++ b/web/src/app/(app)/lists/[id]/page.tsx @@ -1,6 +1,6 @@ export const dynamic = "force-dynamic"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { getAuthenticatedUser } from "@/lib/auth"; import { db } from "@/db/client"; import { slLists, slTasks } from "@/db/schema"; @@ -17,7 +17,8 @@ export default async function ListPage({ searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { const user = await getAuthenticatedUser(); - if (!user) notFound(); + if (!user) redirect("/auth"); + const userId = user.userId; const { id: listId } = await params; const search = await searchParams; @@ -29,7 +30,7 @@ export default async function ListPage({ .where( and( eq(slLists.id, listId), - eq(slLists.userId, user.userId), + eq(slLists.userId, userId), isNull(slLists.deletedAt) ) ); @@ -39,7 +40,7 @@ export default async function ListPage({ // Build conditions const conditions: SQL[] = [ eq(slTasks.listId, listId), - eq(slTasks.userId, user.userId), + eq(slTasks.userId, userId), isNull(slTasks.deletedAt), isNull(slTasks.parentId), ]; @@ -81,13 +82,12 @@ export default async function ListPage({ .from(slTasks) .where( and( - eq(slTasks.userId, user.userId), + eq(slTasks.userId, userId), isNull(slTasks.deletedAt) ) ) .orderBy(asc(slTasks.position)); - // Filter subtasks whose parentId is in our task list const parentIdSet = new Set(parentIds); for (const sub of allSubtasks) { if (sub.parentId && parentIdSet.has(sub.parentId)) { diff --git a/web/src/app/(app)/page.tsx b/web/src/app/(app)/page.tsx index 8e60904..d789c25 100644 --- a/web/src/app/(app)/page.tsx +++ b/web/src/app/(app)/page.tsx @@ -3,34 +3,24 @@ import { getAuthenticatedUser } from "@/lib/auth"; import { db } from "@/db/client"; import { slLists } from "@/db/schema"; import { eq, isNull, and, asc } from "drizzle-orm"; +import { WelcomeMessage } from "@/components/WelcomeMessage"; export const dynamic = "force-dynamic"; export default async function AppHome() { const user = await getAuthenticatedUser(); if (!user) redirect("/auth"); + const userId = user.userId; const lists = await db .select() .from(slLists) - .where(and(eq(slLists.userId, user.userId), isNull(slLists.deletedAt))) + .where(and(eq(slLists.userId, userId), isNull(slLists.deletedAt))) .orderBy(asc(slLists.position)); - // Redirect to inbox, or first list, or show empty state const inbox = lists.find((l) => l.isInbox); if (inbox) redirect(`/lists/${inbox.id}`); if (lists.length > 0) redirect(`/lists/${lists[0].id}`); - // No lists at all — show a message (the sidebar will show "Nouvelle liste" button) - return ( -
-
-

Bienvenue sur Simpl-Liste

-

- Créez votre première liste en utilisant le bouton dans la barre - latérale. -

-
-
- ); + return ; } diff --git a/web/src/app/api/debug/route.ts b/web/src/app/api/debug/route.ts new file mode 100644 index 0000000..f3198ae --- /dev/null +++ b/web/src/app/api/debug/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; +import { getLogtoContext } from '@logto/next/server-actions'; +import { logtoConfig } from '@/lib/logto'; +import { cookies } from 'next/headers'; +import { db } from '@/db/client'; +import { slLists } from '@/db/schema'; +import { eq, isNull, and, asc } from 'drizzle-orm'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const cookieStore = await cookies(); + const allCookies = cookieStore.getAll().map(c => ({ name: c.name, length: c.value.length })); + + try { + const context = await getLogtoContext(logtoConfig); + const userId = context.claims?.sub; + + let lists = null; + if (userId) { + lists = await db + .select({ id: slLists.id, name: slLists.name, isInbox: slLists.isInbox, userId: slLists.userId }) + .from(slLists) + .where(and(eq(slLists.userId, userId), isNull(slLists.deletedAt))) + .orderBy(asc(slLists.position)); + } + + return NextResponse.json({ + cookies: allCookies, + isAuthenticated: context.isAuthenticated, + claims: context.claims ?? null, + lists, + }); + } catch (error) { + return NextResponse.json({ + cookies: allCookies, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/web/src/app/api/lists/[id]/route.ts b/web/src/app/api/lists/[id]/route.ts index 5d78685..59b40dc 100644 --- a/web/src/app/api/lists/[id]/route.ts +++ b/web/src/app/api/lists/[id]/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db/client'; import { slLists } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { updateListSchema } from '@/lib/validators'; export async function PUT( @@ -11,6 +12,8 @@ export async function PUT( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const { id } = await params; const body = await parseBody(request, (d) => updateListSchema.parse(d)); @@ -35,6 +38,8 @@ export async function DELETE( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const { id } = await params; diff --git a/web/src/app/api/lists/[id]/tasks/route.ts b/web/src/app/api/lists/[id]/tasks/route.ts index 35f866b..1e5ee50 100644 --- a/web/src/app/api/lists/[id]/tasks/route.ts +++ b/web/src/app/api/lists/[id]/tasks/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db/client'; import { slTasks, slLists, slTaskTags } from '@/db/schema'; import { eq, and, isNull, asc, desc, inArray, SQL } from 'drizzle-orm'; import { requireAuth } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; export async function GET( request: NextRequest, @@ -10,6 +11,8 @@ export async function GET( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'read'); + if (rl) return rl; const { id: listId } = await params; diff --git a/web/src/app/api/lists/reorder/route.ts b/web/src/app/api/lists/reorder/route.ts index d680523..885689e 100644 --- a/web/src/app/api/lists/reorder/route.ts +++ b/web/src/app/api/lists/reorder/route.ts @@ -3,11 +3,14 @@ import { db } from '@/db/client'; import { slLists } from '@/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { reorderSchema } from '@/lib/validators'; export async function PUT(request: Request) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const body = await parseBody(request, (d) => reorderSchema.parse(d)); if (body.error) return body.error; diff --git a/web/src/app/api/lists/route.ts b/web/src/app/api/lists/route.ts index 7a2d47f..f300a74 100644 --- a/web/src/app/api/lists/route.ts +++ b/web/src/app/api/lists/route.ts @@ -3,11 +3,14 @@ import { db } from '@/db/client'; import { slLists } from '@/db/schema'; import { eq, isNull, and, asc } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { createListSchema } from '@/lib/validators'; export async function GET() { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'read'); + if (rl) return rl; const lists = await db .select() @@ -21,6 +24,8 @@ export async function GET() { export async function POST(request: Request) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const body = await parseBody(request, (d) => createListSchema.parse(d)); if (body.error) return body.error; diff --git a/web/src/app/api/logto/callback/route.ts b/web/src/app/api/logto/callback/route.ts index d2e6be7..6ffbd82 100644 --- a/web/src/app/api/logto/callback/route.ts +++ b/web/src/app/api/logto/callback/route.ts @@ -1,17 +1,12 @@ import { handleSignIn } from '@logto/next/server-actions'; import { logtoConfig } from '@/lib/logto'; -import { NextRequest } from 'next/server'; import { redirect } from 'next/navigation'; +import { type NextRequest } from 'next/server'; export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; - - try { - await handleSignIn(logtoConfig, searchParams); - redirect('/'); - } catch { - redirect('/?error=auth'); - } + await handleSignIn(logtoConfig, searchParams); + redirect('/'); } diff --git a/web/src/app/api/logto/sign-in/route.ts b/web/src/app/api/logto/sign-in/route.ts index dfebc30..82c1d9e 100644 --- a/web/src/app/api/logto/sign-in/route.ts +++ b/web/src/app/api/logto/sign-in/route.ts @@ -1,9 +1,9 @@ import { signIn } from '@logto/next/server-actions'; import { logtoConfig } from '@/lib/logto'; -import { NextRequest } from 'next/server'; export const dynamic = 'force-dynamic'; -export async function GET(request: NextRequest) { +export async function GET() { + // signIn calls redirect() internally — must not be in try/catch await signIn(logtoConfig, `${logtoConfig.baseUrl}/api/logto/callback`); } diff --git a/web/src/app/api/logto/sign-out/route.ts b/web/src/app/api/logto/sign-out/route.ts index dc24a01..a97ecce 100644 --- a/web/src/app/api/logto/sign-out/route.ts +++ b/web/src/app/api/logto/sign-out/route.ts @@ -1,8 +1,16 @@ import { signOut } from '@logto/next/server-actions'; import { logtoConfig } from '@/lib/logto'; +import { cookies } from 'next/headers'; export const dynamic = 'force-dynamic'; export async function GET() { - await signOut(logtoConfig, `${logtoConfig.baseUrl}`); + // Clear the Logto session cookie explicitly + const cookieStore = await cookies(); + const logtoCookie = cookieStore.getAll().find(c => c.name.startsWith('logto_')); + if (logtoCookie) { + cookieStore.delete(logtoCookie.name); + } + + await signOut(logtoConfig, logtoConfig.baseUrl); } diff --git a/web/src/app/api/sync/route.ts b/web/src/app/api/sync/route.ts index 6d527f2..6f98e5b 100644 --- a/web/src/app/api/sync/route.ts +++ b/web/src/app/api/sync/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db/client'; import { slLists, slTasks, slTags, slTaskTags } from '@/db/schema'; import { eq, and, gte } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { syncPushSchema, type SyncOperation } from '@/lib/validators'; // Idempotency key store (TTL 24h) @@ -23,6 +24,8 @@ const TTL_24H = 24 * 60 * 60 * 1000; export async function GET(request: NextRequest) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'sync'); + if (rl) return rl; const since = request.nextUrl.searchParams.get('since'); if (!since) { @@ -73,6 +76,8 @@ export async function GET(request: NextRequest) { export async function POST(request: Request) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'sync'); + if (rl) return rl; const body = await parseBody(request, (d) => syncPushSchema.parse(d)); if (body.error) return body.error; diff --git a/web/src/app/api/tags/[id]/route.ts b/web/src/app/api/tags/[id]/route.ts index 606195d..3c4a1f3 100644 --- a/web/src/app/api/tags/[id]/route.ts +++ b/web/src/app/api/tags/[id]/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db/client'; import { slTags } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { updateTagSchema } from '@/lib/validators'; export async function PUT( @@ -11,6 +12,8 @@ export async function PUT( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const { id } = await params; const body = await parseBody(request, (d) => updateTagSchema.parse(d)); @@ -35,6 +38,8 @@ export async function DELETE( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const { id } = await params; diff --git a/web/src/app/api/tags/route.ts b/web/src/app/api/tags/route.ts index 082744a..aa7d48f 100644 --- a/web/src/app/api/tags/route.ts +++ b/web/src/app/api/tags/route.ts @@ -3,11 +3,14 @@ import { db } from '@/db/client'; import { slTags } from '@/db/schema'; import { eq, isNull, and, asc } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { createTagSchema } from '@/lib/validators'; export async function GET() { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'read'); + if (rl) return rl; const tags = await db .select() @@ -21,6 +24,8 @@ export async function GET() { export async function POST(request: Request) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const body = await parseBody(request, (d) => createTagSchema.parse(d)); if (body.error) return body.error; diff --git a/web/src/app/api/tasks/[id]/route.ts b/web/src/app/api/tasks/[id]/route.ts index 93e5df3..3bc57f3 100644 --- a/web/src/app/api/tasks/[id]/route.ts +++ b/web/src/app/api/tasks/[id]/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db/client'; import { slTasks } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { updateTaskSchema } from '@/lib/validators'; export async function PUT( @@ -11,6 +12,8 @@ export async function PUT( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const { id } = await params; const body = await parseBody(request, (d) => updateTaskSchema.parse(d)); @@ -50,6 +53,8 @@ export async function DELETE( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const { id } = await params; diff --git a/web/src/app/api/tasks/[id]/subtasks/route.ts b/web/src/app/api/tasks/[id]/subtasks/route.ts index 305ab55..64445db 100644 --- a/web/src/app/api/tasks/[id]/subtasks/route.ts +++ b/web/src/app/api/tasks/[id]/subtasks/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db/client'; import { slTasks } from '@/db/schema'; import { eq, and, isNull, asc } from 'drizzle-orm'; import { requireAuth } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; export async function GET( _request: Request, @@ -10,6 +11,8 @@ export async function GET( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'read'); + if (rl) return rl; const { id } = await params; diff --git a/web/src/app/api/tasks/[id]/tags/[tagId]/route.ts b/web/src/app/api/tasks/[id]/tags/[tagId]/route.ts index f340fac..32e9b55 100644 --- a/web/src/app/api/tasks/[id]/tags/[tagId]/route.ts +++ b/web/src/app/api/tasks/[id]/tags/[tagId]/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db/client'; import { slTasks, slTaskTags } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; import { requireAuth } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; export async function DELETE( _request: Request, @@ -10,6 +11,8 @@ export async function DELETE( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const { id: taskId, tagId } = await params; diff --git a/web/src/app/api/tasks/[id]/tags/route.ts b/web/src/app/api/tasks/[id]/tags/route.ts index 76f579c..04139ad 100644 --- a/web/src/app/api/tasks/[id]/tags/route.ts +++ b/web/src/app/api/tasks/[id]/tags/route.ts @@ -3,6 +3,7 @@ import { db } from '@/db/client'; import { slTasks, slTags, slTaskTags } from '@/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { assignTagsSchema } from '@/lib/validators'; export async function POST( @@ -11,6 +12,8 @@ export async function POST( ) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const { id: taskId } = await params; diff --git a/web/src/app/api/tasks/reorder/route.ts b/web/src/app/api/tasks/reorder/route.ts index 64fef6e..1db89d8 100644 --- a/web/src/app/api/tasks/reorder/route.ts +++ b/web/src/app/api/tasks/reorder/route.ts @@ -3,11 +3,14 @@ import { db } from '@/db/client'; import { slTasks } from '@/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { reorderSchema } from '@/lib/validators'; export async function PUT(request: Request) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const body = await parseBody(request, (d) => reorderSchema.parse(d)); if (body.error) return body.error; diff --git a/web/src/app/api/tasks/route.ts b/web/src/app/api/tasks/route.ts index 4fec9d9..aeaf4a2 100644 --- a/web/src/app/api/tasks/route.ts +++ b/web/src/app/api/tasks/route.ts @@ -3,11 +3,14 @@ import { db } from '@/db/client'; import { slTasks, slLists } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; import { requireAuth, parseBody } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { createTaskSchema } from '@/lib/validators'; export async function POST(request: Request) { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'create'); + if (rl) return rl; const body = await parseBody(request, (d) => createTaskSchema.parse(d)); if (body.error) return body.error; diff --git a/web/src/app/api/ws-ticket/route.ts b/web/src/app/api/ws-ticket/route.ts index 659bffb..fe7021d 100644 --- a/web/src/app/api/ws-ticket/route.ts +++ b/web/src/app/api/ws-ticket/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { randomUUID } from 'crypto'; import { requireAuth } from '@/lib/api'; +import { rateLimit } from '@/lib/rateLimit'; import { getTicketStore } from '@/lib/ws'; const TTL_30S = 30 * 1000; @@ -18,6 +19,8 @@ function cleanupTickets() { export async function POST() { const auth = await requireAuth(); if (auth.error) return auth.error; + const rl = rateLimit(auth.userId, 'ws-ticket'); + if (rl) return rl; cleanupTickets(); diff --git a/web/src/app/auth/page.tsx b/web/src/app/auth/page.tsx index 9774a5b..ca163e6 100644 --- a/web/src/app/auth/page.tsx +++ b/web/src/app/auth/page.tsx @@ -1,18 +1,21 @@ -import Link from 'next/link'; +"use client"; + +import Link from "next/link"; +import { useTranslation } from "react-i18next"; export default function AuthPage() { + const { t } = useTranslation(); + return (
-

Simpl-Liste

-

- Connectez-vous avec votre Compte Maximus -

+

{t("app.name")}

+

{t("auth.subtitle")}

- Se connecter + {t("auth.signIn")}
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4d808d9..187419b 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ThemeScript } from "@/components/ThemeScript"; +import { I18nProvider } from "@/components/I18nProvider"; import "./globals.css"; const geistSans = Geist({ @@ -32,7 +33,9 @@ export default function RootLayout({ - {children} + + {children} + ); } diff --git a/web/src/components/AuthContext.tsx b/web/src/components/AuthContext.tsx new file mode 100644 index 0000000..196116f --- /dev/null +++ b/web/src/components/AuthContext.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { createContext, useContext } from "react"; + +interface AuthUser { + userId: string; + email?: string | null; + name?: string | null; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ + user, + children, +}: { + user: AuthUser; + children: React.ReactNode; +}) { + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx index 26620eb..77debd8 100644 --- a/web/src/components/FilterBar.tsx +++ b/web/src/components/FilterBar.tsx @@ -2,26 +2,28 @@ import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { Filter, ArrowUpDown } from "lucide-react"; - -const STATUS_OPTIONS = [ - { value: "", label: "Toutes" }, - { value: "false", label: "Actives" }, - { value: "true", label: "Complétées" }, -]; - -const SORT_OPTIONS = [ - { value: "position", label: "Position" }, - { value: "priority", label: "Priorité" }, - { value: "dueDate", label: "Échéance" }, - { value: "title", label: "Titre" }, - { value: "createdAt", label: "Date de création" }, -]; +import { useTranslation } from "react-i18next"; export function FilterBar() { + const { t } = useTranslation(); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const STATUS_OPTIONS = [ + { value: "", label: t("filter.all") }, + { value: "false", label: t("filter.active") }, + { value: "true", label: t("filter.completed") }, + ]; + + const SORT_OPTIONS = [ + { value: "position", label: t("sort.position") }, + { value: "priority", label: t("sort.priority") }, + { value: "dueDate", label: t("sort.dueDate") }, + { value: "title", label: t("sort.title") }, + { value: "createdAt", label: t("sort.createdAt") }, + ]; + const completed = searchParams.get("completed") ?? ""; const sortBy = searchParams.get("sortBy") ?? "position"; const sortOrder = searchParams.get("sortOrder") ?? "asc"; @@ -73,7 +75,7 @@ export function FilterBar() { updateParam("sortOrder", sortOrder === "asc" ? "desc" : "asc") } className="px-1.5 py-1 border border-border-light dark:border-border-dark rounded hover:bg-black/5 dark:hover:bg-white/5" - title={sortOrder === "asc" ? "Croissant" : "Décroissant"} + title={sortOrder === "asc" ? t("sort.asc") : t("sort.desc")} > {sortOrder === "asc" ? "↑" : "↓"} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index a50f025..f932bbb 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -4,12 +4,14 @@ import { ThemeToggle } from "./ThemeToggle"; import { User, LogOut } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; interface HeaderProps { userName: string; } export function Header({ userName }: HeaderProps) { + const { t } = useTranslation(); const [menuOpen, setMenuOpen] = useState(false); return ( @@ -18,7 +20,7 @@ export function Header({ userName }: HeaderProps) {
- Simpl-Liste + {t("app.name")}
@@ -52,7 +54,7 @@ export function Header({ userName }: HeaderProps) { onClick={() => setMenuOpen(false)} > - Se déconnecter + {t("auth.signOut")}
diff --git a/web/src/components/I18nProvider.tsx b/web/src/components/I18nProvider.tsx new file mode 100644 index 0000000..d9f7ef3 --- /dev/null +++ b/web/src/components/I18nProvider.tsx @@ -0,0 +1,7 @@ +"use client"; + +import "@/i18n"; + +export function I18nProvider({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 014f854..517fa3c 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -5,14 +5,15 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Inbox, - List, Plus, Tag, Menu, X, ChevronDown, ChevronRight, + LogOut, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import type { List as ListType, Tag as TagType } from "@/lib/types"; interface SidebarProps { @@ -21,6 +22,7 @@ interface SidebarProps { } export function Sidebar({ lists, tags }: SidebarProps) { + const { t } = useTranslation(); const pathname = usePathname(); const router = useRouter(); const [mobileOpen, setMobileOpen] = useState(false); @@ -45,13 +47,13 @@ export function Sidebar({ lists, tags }: SidebarProps) {
{/* Header */}
-

Simpl-Liste

+

{t("app.name")}

{/* Lists */}
@@ -103,7 +105,7 @@ export function Sidebar({ lists, tags }: SidebarProps) { className="flex items-center gap-2 px-3 py-2 text-sm text-foreground/60 hover:text-foreground transition-colors w-full" > - Nouvelle liste + {t("sidebar.newList")} )} @@ -118,7 +120,7 @@ export function Sidebar({ lists, tags }: SidebarProps) { ) : ( )} - Étiquettes + {t("sidebar.tags")} {tagsExpanded && tags.map((tag) => ( @@ -139,8 +141,8 @@ export function Sidebar({ lists, tags }: SidebarProps) { href="/api/logto/sign-out" className="flex items-center gap-2 text-sm text-foreground/60 hover:text-rouge transition-colors" > - - Se déconnecter + + {t("auth.signOut")}
diff --git a/web/src/components/TaskForm.tsx b/web/src/components/TaskForm.tsx index ecf017a..7f9f493 100644 --- a/web/src/components/TaskForm.tsx +++ b/web/src/components/TaskForm.tsx @@ -3,13 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { Plus, X } from "lucide-react"; - -const PRIORITY_LABELS = [ - { value: 0, label: "Aucune", color: "" }, - { value: 1, label: "Basse", color: "text-vert" }, - { value: 2, label: "Moyenne", color: "text-sable" }, - { value: 3, label: "Haute", color: "text-rouge" }, -]; +import { useTranslation } from "react-i18next"; interface TaskFormProps { listId: string; @@ -18,6 +12,7 @@ interface TaskFormProps { } export function TaskForm({ listId, parentId, onClose }: TaskFormProps) { + const { t } = useTranslation(); const router = useRouter(); const [title, setTitle] = useState(""); const [notes, setNotes] = useState(""); @@ -27,6 +22,13 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) { const [expanded, setExpanded] = useState(false); const [submitting, setSubmitting] = useState(false); + const PRIORITY_LABELS = [ + { value: 0, label: t("priority.none"), color: "" }, + { value: 1, label: t("priority.low"), color: "text-vert" }, + { value: 2, label: t("priority.medium"), color: "text-sable" }, + { value: 3, label: t("priority.high"), color: "text-rouge" }, + ]; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!title.trim() || submitting) return; @@ -66,7 +68,7 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) { className="flex items-center gap-2 w-full px-4 py-3 text-sm text-foreground/60 hover:text-foreground border border-dashed border-border-light dark:border-border-dark rounded-lg hover:border-bleu transition-colors" > - Ajouter une tâche + {t("task.add")} ); } @@ -81,7 +83,7 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) { autoFocus value={title} onChange={(e) => setTitle(e.target.value)} - placeholder={parentId ? "Nouvelle sous-tâche..." : "Titre de la tâche..."} + placeholder={parentId ? t("task.subtaskPlaceholder") : t("task.titlePlaceholder")} className="flex-1 bg-transparent text-sm focus:outline-none placeholder:text-foreground/40" /> {!parentId && ( @@ -101,7 +103,7 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {