fix: resolve Logto auth crash on web — remove illegal cookie set in layout

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) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-04-08 13:12:59 -04:00
parent 14c208be46
commit f786947941
60 changed files with 1771 additions and 218 deletions

View file

@ -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 <N>`
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

View file

@ -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

View file

@ -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();

View file

@ -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 (
<LogtoProvider config={logtoConfig}>
<AppContent />
</LogtoProvider>
);
}
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 (
<GestureHandlerRootView style={{ flex: 1 }}>

172
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

23
src/lib/authToken.ts Normal file
View file

@ -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<string>;
let _getAccessToken: TokenGetter | null = null;
export function setTokenGetter(getter: TokenGetter): void {
_getAccessToken = getter;
}
export function clearTokenGetter(): void {
_getAccessToken = null;
}
export async function getAccessToken(): Promise<string | null> {
if (!_getAccessToken) return null;
try {
return await _getAccessToken();
} catch {
return null;
}
}

11
src/lib/logtoConfig.ts Normal file
View file

@ -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://';

View file

@ -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<string, unknown>;
}
interface SyncPullChange {
@ -27,21 +29,45 @@ interface SyncPullResponse {
sync_token: string;
}
function getAuthHeaders(): Record<string, string> {
const { userId } = useSettingsStore.getState();
if (!userId) return {};
// Placeholder: in real implementation, JWT from Logto would be used
async function getAuthHeaders(): Promise<Record<string, string>> {
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<string, string>): Promise<boolean> {
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<void> {
const headers = getAuthHeaders();
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
const unsynced = await db
@ -51,42 +77,25 @@ export async function pushChanges(): Promise<void> {
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<void> {
* Pull changes from the server since the last sync timestamp.
*/
export async function pullChanges(since: string): Promise<void> {
const headers = getAuthHeaders();
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
try {
@ -261,6 +270,149 @@ export async function fullSync(): Promise<void> {
}
}
/**
* 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<void> {
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<string, string> = {};
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<void> {
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.

View file

@ -14,5 +14,8 @@
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": [
"web"
]
}

View file

@ -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"]

108
web/package-lock.json generated
View file

@ -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",

View file

@ -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"
},

View file

@ -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

View file

@ -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)) {

View file

@ -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 (
<div className="flex items-center justify-center h-full text-foreground/50">
<div className="text-center space-y-2">
<p className="text-lg">Bienvenue sur Simpl-Liste</p>
<p className="text-sm">
Créez votre première liste en utilisant le bouton dans la barre
latérale.
</p>
</div>
</div>
);
return <WelcomeMessage />;
}

View file

@ -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),
});
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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('/');
}

View file

@ -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`);
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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();

View file

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-[#FFF8F0]">
<div className="text-center space-y-6 p-8">
<h1 className="text-3xl font-bold text-[#1A1A1A]">Simpl-Liste</h1>
<p className="text-[#6B6B6B]">
Connectez-vous avec votre Compte Maximus
</p>
<h1 className="text-3xl font-bold text-[#1A1A1A]">{t("app.name")}</h1>
<p className="text-[#6B6B6B]">{t("auth.subtitle")}</p>
<Link
href="/api/logto/sign-in"
className="inline-block px-6 py-3 bg-[#4A90A4] text-white rounded-lg font-medium hover:bg-[#3A7389] transition-colors"
>
Se connecter
{t("auth.signIn")}
</Link>
</div>
</div>

View file

@ -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({
<head>
<ThemeScript />
</head>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<I18nProvider>{children}</I18nProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,27 @@
"use client";
import { createContext, useContext } from "react";
interface AuthUser {
userId: string;
email?: string | null;
name?: string | null;
}
const AuthContext = createContext<AuthUser | null>(null);
export function AuthProvider({
user,
children,
}: {
user: AuthUser;
children: React.ReactNode;
}) {
return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View file

@ -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" ? "↑" : "↓"}
</button>

View file

@ -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) {
<div className="w-10 md:hidden" />
<div className="hidden md:block text-sm font-medium text-bleu">
Simpl-Liste
{t("app.name")}
</div>
<div className="flex items-center gap-2">
@ -52,7 +54,7 @@ export function Header({ userName }: HeaderProps) {
onClick={() => setMenuOpen(false)}
>
<LogOut size={14} />
Se déconnecter
{t("auth.signOut")}
</Link>
</div>
</>

View file

@ -0,0 +1,7 @@
"use client";
import "@/i18n";
export function I18nProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View file

@ -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) {
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-border-light dark:border-border-dark">
<h1 className="text-lg font-bold text-bleu">Simpl-Liste</h1>
<h1 className="text-lg font-bold text-bleu">{t("app.name")}</h1>
</div>
{/* Lists */}
<nav className="flex-1 overflow-y-auto p-2 space-y-1">
<p className="px-3 py-1 text-xs font-semibold uppercase text-foreground/50">
Listes
{t("sidebar.lists")}
</p>
{lists.map((list) => {
const isActive = pathname === `/lists/${list.id}`;
@ -93,7 +95,7 @@ export function Sidebar({ lists, tags }: SidebarProps) {
setNewListName("");
}
}}
placeholder="Nom de la liste..."
placeholder={t("sidebar.newListPlaceholder")}
className="w-full px-2 py-1 text-sm border border-border-light dark:border-border-dark rounded bg-transparent focus:outline-none focus:border-bleu"
/>
</div>
@ -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"
>
<Plus size={16} />
Nouvelle liste
{t("sidebar.newList")}
</button>
)}
@ -118,7 +120,7 @@ export function Sidebar({ lists, tags }: SidebarProps) {
) : (
<ChevronRight size={12} />
)}
Étiquettes
{t("sidebar.tags")}
</button>
{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"
>
<List size={16} />
Se déconnecter
<LogOut size={16} />
{t("auth.signOut")}
</Link>
</div>
</div>

View file

@ -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"
>
<Plus size={16} />
Ajouter une tâche
{t("task.add")}
</button>
);
}
@ -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) {
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes..."
placeholder={t("task.notesPlaceholder")}
rows={2}
className="w-full bg-transparent text-sm border border-border-light dark:border-border-dark rounded px-2 py-1 focus:outline-none focus:border-bleu resize-none placeholder:text-foreground/40"
/>
@ -131,11 +133,11 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {
onChange={(e) => setRecurrence(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
<option value="">Pas de récurrence</option>
<option value="daily">Quotidienne</option>
<option value="weekly">Hebdomadaire</option>
<option value="monthly">Mensuelle</option>
<option value="yearly">Annuelle</option>
<option value="">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
@ -149,14 +151,14 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {
}}
className="px-3 py-1.5 text-sm text-foreground/60 hover:text-foreground"
>
Annuler
{t("task.cancel")}
</button>
<button
type="submit"
disabled={!title.trim() || submitting}
className="px-3 py-1.5 text-sm bg-bleu text-white rounded-lg hover:bg-bleu/90 disabled:opacity-50 transition-colors"
>
{submitting ? "..." : "Ajouter"}
{submitting ? "..." : t("task.addBtn")}
</button>
</div>
</form>

View file

@ -10,6 +10,7 @@ import {
Repeat,
Check,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Task } from "@/lib/types";
import { TaskForm } from "./TaskForm";
@ -20,13 +21,6 @@ const PRIORITY_COLORS: Record<number, string> = {
3: "border-l-rouge",
};
const PRIORITY_LABELS: Record<number, string> = {
0: "Aucune",
1: "Basse",
2: "Moyenne",
3: "Haute",
};
function formatDate(dateStr: string | Date | null): string {
if (!dateStr) return "";
const d = new Date(dateStr);
@ -36,17 +30,6 @@ function formatDate(dateStr: string | Date | null): string {
});
}
function recurrenceLabel(r: string | null): string {
if (!r) return "";
const map: Record<string, string> = {
daily: "Quotidienne",
weekly: "Hebdomadaire",
monthly: "Mensuelle",
yearly: "Annuelle",
};
return map[r] || r;
}
interface TaskItemProps {
task: Task;
subtasks?: Task[];
@ -54,6 +37,7 @@ interface TaskItemProps {
}
export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
const { t } = useTranslation();
const router = useRouter();
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
@ -67,6 +51,20 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
const [showSubtaskForm, setShowSubtaskForm] = useState(false);
const [saving, setSaving] = useState(false);
const PRIORITY_LABELS: Record<number, string> = {
0: t("priority.none"),
1: t("priority.low"),
2: t("priority.medium"),
3: t("priority.high"),
};
const RECURRENCE_LABELS: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
// Sync state when task prop changes
useEffect(() => {
setTitle(task.title);
@ -191,7 +189,7 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes..."
placeholder={t("task.notesPlaceholder")}
rows={2}
className="w-full bg-transparent text-sm border border-border-light dark:border-border-dark rounded px-2 py-1 focus:outline-none focus:border-bleu resize-none placeholder:text-foreground/40"
/>
@ -201,10 +199,10 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
onChange={(e) => setPriority(Number(e.target.value))}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
>
<option value={0}>Aucune priorité</option>
<option value={1}>Basse</option>
<option value={2}>Moyenne</option>
<option value={3}>Haute</option>
<option value={0}>{t("priority.noneExplicit")}</option>
<option value={1}>{t("priority.low")}</option>
<option value={2}>{t("priority.medium")}</option>
<option value={3}>{t("priority.high")}</option>
</select>
<input
type="date"
@ -217,11 +215,11 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
onChange={(e) => setRecurrence(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
>
<option value="">Pas de récurrence</option>
<option value="daily">Quotidienne</option>
<option value="weekly">Hebdomadaire</option>
<option value="monthly">Mensuelle</option>
<option value="yearly">Annuelle</option>
<option value="">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div className="flex gap-2 justify-end">
@ -229,14 +227,14 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
onClick={() => setEditing(false)}
className="px-3 py-1 text-sm text-foreground/60 hover:text-foreground"
>
Annuler
{t("task.cancel")}
</button>
<button
onClick={saveEdit}
disabled={!title.trim() || saving}
className="px-3 py-1 text-sm bg-bleu text-white rounded hover:bg-bleu/90 disabled:opacity-50"
>
{saving ? "..." : "Enregistrer"}
{saving ? "..." : t("task.save")}
</button>
</div>
</div>
@ -247,13 +245,13 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
)}
<div className="flex flex-wrap gap-2 text-xs text-foreground/50">
{task.priority > 0 && (
<span>Priorité : {PRIORITY_LABELS[task.priority]}</span>
<span>{t("task.priorityLabel", { value: PRIORITY_LABELS[task.priority] })}</span>
)}
{task.dueDate && (
<span>Échéance : {formatDate(task.dueDate)}</span>
<span>{t("task.dueDate", { value: formatDate(task.dueDate) })}</span>
)}
{task.recurrence && (
<span>Récurrence : {recurrenceLabel(task.recurrence)}</span>
<span>{t("task.recurrenceLabel", { value: RECURRENCE_LABELS[task.recurrence] || task.recurrence })}</span>
)}
</div>
<div className="flex gap-2 pt-1">
@ -261,20 +259,20 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
onClick={() => setEditing(true)}
className="text-xs text-bleu hover:underline"
>
Modifier
{t("task.edit")}
</button>
<button
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
className="text-xs text-bleu hover:underline"
>
+ Sous-tâche
{t("task.addSubtask")}
</button>
<button
onClick={deleteTask}
className="text-xs text-rouge hover:underline flex items-center gap-1"
>
<Trash2 size={12} />
Supprimer
{t("task.delete")}
</button>
</div>
</div>

View file

@ -6,6 +6,7 @@ import { TaskForm } from "./TaskForm";
import { FilterBar } from "./FilterBar";
import { ClipboardList } from "lucide-react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
interface TaskListProps {
tasks: Task[];
@ -15,6 +16,8 @@ interface TaskListProps {
}
export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps) {
const { t } = useTranslation();
return (
<div className="max-w-2xl mx-auto w-full">
{/* Header */}
@ -34,7 +37,7 @@ export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps
{tasks.length === 0 ? (
<div className="text-center py-12 text-foreground/40">
<ClipboardList size={48} className="mx-auto mb-3 opacity-50" />
<p>Aucune tâche</p>
<p>{t("task.empty")}</p>
</div>
) : (
<div className="space-y-0">

View file

@ -2,10 +2,12 @@
import { useState, useEffect } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTranslation } from "react-i18next";
type Theme = "light" | "dark" | "system";
export function ThemeToggle() {
const { t } = useTranslation();
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
@ -27,12 +29,13 @@ export function ThemeToggle() {
};
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel = t(`theme.${theme}`);
return (
<button
onClick={cycle}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title={`Thème : ${theme === "light" ? "clair" : theme === "dark" ? "sombre" : "système"}`}
title={t("theme.label", { value: themeLabel })}
>
<Icon size={20} />
</button>

View file

@ -0,0 +1,16 @@
"use client";
import { useTranslation } from "react-i18next";
export function WelcomeMessage() {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center h-full text-foreground/50">
<div className="text-center space-y-2">
<p className="text-lg">{t("welcome.title")}</p>
<p className="text-sm">{t("welcome.message")}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,54 @@
CREATE TABLE "sl_lists" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"color" text,
"icon" text,
"position" integer DEFAULT 0 NOT NULL,
"is_inbox" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "sl_tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"color" text DEFAULT '#4A90A4' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "sl_task_tags" (
"task_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
CONSTRAINT "sl_task_tags_task_id_tag_id_pk" PRIMARY KEY("task_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "sl_tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"title" text NOT NULL,
"notes" text,
"completed" boolean DEFAULT false NOT NULL,
"completed_at" timestamp with time zone,
"priority" integer DEFAULT 0 NOT NULL,
"due_date" timestamp with time zone,
"list_id" uuid NOT NULL,
"parent_id" uuid,
"position" integer DEFAULT 0 NOT NULL,
"recurrence" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "sl_task_tags" ADD CONSTRAINT "sl_task_tags_task_id_sl_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."sl_tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sl_task_tags" ADD CONSTRAINT "sl_task_tags_tag_id_sl_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."sl_tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sl_tasks" ADD CONSTRAINT "sl_tasks_list_id_sl_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."sl_lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_sl_lists_user" ON "sl_lists" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tags_user" ON "sl_tags" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_user" ON "sl_tasks" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_list" ON "sl_tasks" USING btree ("list_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_parent" ON "sl_tasks" USING btree ("parent_id");

View file

@ -0,0 +1,3 @@
ALTER TABLE "sl_lists" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "sl_tasks" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "sl_tags" ALTER COLUMN "user_id" SET DATA TYPE text;

View file

@ -0,0 +1,410 @@
{
"id": "a1bf2951-6318-42a2-adbb-758a703deb0b",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.sl_lists": {
"name": "sl_lists",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"is_inbox": {
"name": "is_inbox",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_lists_user": {
"name": "idx_sl_lists_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_tags": {
"name": "sl_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'#4A90A4'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_tags_user": {
"name": "idx_sl_tags_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_task_tags": {
"name": "sl_task_tags",
"schema": "",
"columns": {
"task_id": {
"name": "task_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sl_task_tags_task_id_sl_tasks_id_fk": {
"name": "sl_task_tags_task_id_sl_tasks_id_fk",
"tableFrom": "sl_task_tags",
"tableTo": "sl_tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sl_task_tags_tag_id_sl_tags_id_fk": {
"name": "sl_task_tags_tag_id_sl_tags_id_fk",
"tableFrom": "sl_task_tags",
"tableTo": "sl_tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sl_task_tags_task_id_tag_id_pk": {
"name": "sl_task_tags_task_id_tag_id_pk",
"columns": [
"task_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_tasks": {
"name": "sl_tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"completed": {
"name": "completed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"list_id": {
"name": "list_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"recurrence": {
"name": "recurrence",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_tasks_user": {
"name": "idx_sl_tasks_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_sl_tasks_list": {
"name": "idx_sl_tasks_list",
"columns": [
{
"expression": "list_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_sl_tasks_parent": {
"name": "idx_sl_tasks_parent",
"columns": [
{
"expression": "parent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sl_tasks_list_id_sl_lists_id_fk": {
"name": "sl_tasks_list_id_sl_lists_id_fk",
"tableFrom": "sl_tasks",
"tableTo": "sl_lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1775560948559,
"tag": "0000_tidy_mandarin",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1775567900000,
"tag": "0001_change_user_id_to_text",
"breakpoints": true
}
]
}

View file

@ -2,7 +2,7 @@ import { pgTable, uuid, text, integer, boolean, timestamp, primaryKey, index } f
export const slLists = pgTable('sl_lists', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
userId: text('user_id').notNull(),
name: text('name').notNull(),
color: text('color'),
icon: text('icon'),
@ -17,7 +17,7 @@ export const slLists = pgTable('sl_lists', {
export const slTasks = pgTable('sl_tasks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
userId: text('user_id').notNull(),
title: text('title').notNull(),
notes: text('notes'),
completed: boolean('completed').notNull().default(false),
@ -39,7 +39,7 @@ export const slTasks = pgTable('sl_tasks', {
export const slTags = pgTable('sl_tags', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
userId: text('user_id').notNull(),
name: text('name').notNull(),
color: text('color').notNull().default('#4A90A4'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

73
web/src/i18n/en.json Normal file
View file

@ -0,0 +1,73 @@
{
"app": {
"name": "Simpl-Liste",
"description": "Minimalist task management by La Compagnie Maximus"
},
"auth": {
"signIn": "Sign in",
"signOut": "Sign out",
"subtitle": "Sign in with your Compte Maximus"
},
"sidebar": {
"lists": "Lists",
"tags": "Tags",
"newList": "New list",
"newListPlaceholder": "List name..."
},
"task": {
"add": "Add a task",
"titlePlaceholder": "Task title...",
"subtaskPlaceholder": "New subtask...",
"notesPlaceholder": "Notes...",
"empty": "No tasks",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"addBtn": "Add",
"addSubtask": "+ Subtask",
"priority": "Priority",
"priorityLabel": "Priority: {{value}}",
"dueDate": "Due: {{value}}",
"recurrenceLabel": "Recurrence: {{value}}"
},
"priority": {
"none": "None",
"noneExplicit": "No priority",
"low": "Low",
"medium": "Medium",
"high": "High"
},
"recurrence": {
"none": "No recurrence",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly"
},
"filter": {
"all": "All",
"active": "Active",
"completed": "Completed"
},
"sort": {
"position": "Position",
"priority": "Priority",
"dueDate": "Due date",
"title": "Title",
"createdAt": "Created date",
"asc": "Ascending",
"desc": "Descending"
},
"theme": {
"label": "Theme: {{value}}",
"light": "light",
"dark": "dark",
"system": "system"
},
"welcome": {
"title": "Welcome to Simpl-Liste",
"message": "Create your first list using the button in the sidebar.",
"loading": "Loading..."
}
}

73
web/src/i18n/fr.json Normal file
View file

@ -0,0 +1,73 @@
{
"app": {
"name": "Simpl-Liste",
"description": "Gestion de tâches minimaliste par La Compagnie Maximus"
},
"auth": {
"signIn": "Se connecter",
"signOut": "Se déconnecter",
"subtitle": "Connectez-vous avec votre Compte Maximus"
},
"sidebar": {
"lists": "Listes",
"tags": "Étiquettes",
"newList": "Nouvelle liste",
"newListPlaceholder": "Nom de la liste..."
},
"task": {
"add": "Ajouter une tâche",
"titlePlaceholder": "Titre de la tâche...",
"subtaskPlaceholder": "Nouvelle sous-tâche...",
"notesPlaceholder": "Notes...",
"empty": "Aucune tâche",
"edit": "Modifier",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"addBtn": "Ajouter",
"addSubtask": "+ Sous-tâche",
"priority": "Priorité",
"priorityLabel": "Priorité : {{value}}",
"dueDate": "Échéance : {{value}}",
"recurrenceLabel": "Récurrence : {{value}}"
},
"priority": {
"none": "Aucune",
"noneExplicit": "Aucune priorité",
"low": "Basse",
"medium": "Moyenne",
"high": "Haute"
},
"recurrence": {
"none": "Pas de récurrence",
"daily": "Quotidienne",
"weekly": "Hebdomadaire",
"monthly": "Mensuelle",
"yearly": "Annuelle"
},
"filter": {
"all": "Toutes",
"active": "Actives",
"completed": "Complétées"
},
"sort": {
"position": "Position",
"priority": "Priorité",
"dueDate": "Échéance",
"title": "Titre",
"createdAt": "Date de création",
"asc": "Croissant",
"desc": "Décroissant"
},
"theme": {
"label": "Thème : {{value}}",
"light": "clair",
"dark": "sombre",
"system": "système"
},
"welcome": {
"title": "Bienvenue sur Simpl-Liste",
"message": "Créez votre première liste en utilisant le bouton dans la barre latérale.",
"loading": "Chargement..."
}
}

26
web/src/i18n/index.ts Normal file
View file

@ -0,0 +1,26 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import fr from "./fr.json";
import en from "./en.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
fr: { translation: fr },
en: { translation: en },
},
fallbackLng: "fr",
interpolation: {
escapeValue: false,
},
detection: {
order: ["localStorage", "navigator"],
lookupLocalStorage: "sl-lang",
caches: ["localStorage"],
},
});
export default i18n;

View file

@ -1,13 +1,50 @@
import { getAuthenticatedUser } from '@/lib/auth';
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
/**
* Verify a JWT access token from Logto (for mobile clients).
* Fetches the OIDC userinfo endpoint to validate the token.
*/
async function authenticateBearer(token: string): Promise<{ userId: string } | null> {
try {
const endpoint = process.env.LOGTO_ENDPOINT;
if (!endpoint) return null;
const res = await fetch(`${endpoint}/oidc/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const userinfo = await res.json() as { sub?: string };
if (!userinfo.sub) return null;
return { userId: userinfo.sub };
} catch {
return null;
}
}
/**
* Authenticate the request and return userId or a 401 response.
* Supports both session cookie (web) and Bearer token (mobile).
*/
export async function requireAuth(): Promise<
| { userId: string; error?: never }
| { userId?: never; error: NextResponse }
> {
// Check Bearer token first (mobile clients)
const headersList = await headers();
const authHeader = headersList.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
const user = await authenticateBearer(token);
if (user) return { userId: user.userId };
return { error: NextResponse.json({ error: 'Invalid token' }, { status: 401 }) };
}
// Fall back to session cookie (web clients)
const user = await getAuthenticatedUser();
if (!user) {
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };

View file

@ -2,15 +2,20 @@ import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export async function getAuthenticatedUser() {
const context = await getLogtoContext(logtoConfig);
try {
const context = await getLogtoContext(logtoConfig);
if (!context.isAuthenticated || !context.claims?.sub) {
if (!context.isAuthenticated || !context.claims?.sub) {
return null;
}
return {
userId: context.claims.sub,
email: context.claims.email,
name: context.claims.name,
};
} catch (error) {
console.error('[auth] getLogtoContext error:', error);
return null;
}
return {
userId: context.claims.sub,
email: context.claims.email,
name: context.claims.name,
};
}

56
web/src/lib/rateLimit.ts Normal file
View file

@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
interface RateLimitEntry {
timestamps: number[];
}
const store = new Map<string, RateLimitEntry>();
// Clean up old entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
entry.timestamps = entry.timestamps.filter((t) => now - t < 120_000);
if (entry.timestamps.length === 0) store.delete(key);
}
}, 300_000);
export function rateLimit(
userId: string,
category: "sync" | "create" | "read" | "ws-ticket",
): NextResponse | null {
const limits: Record<string, { max: number; windowMs: number }> = {
sync: { max: 10, windowMs: 60_000 },
create: { max: 30, windowMs: 60_000 },
read: { max: 200, windowMs: 60_000 },
"ws-ticket": { max: 10, windowMs: 60_000 },
};
const { max, windowMs } = limits[category];
const key = `${userId}:${category}`;
const now = Date.now();
let entry = store.get(key);
if (!entry) {
entry = { timestamps: [] };
store.set(key, entry);
}
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
if (entry.timestamps.length >= max) {
const retryAfter = Math.ceil(
(entry.timestamps[0] + windowMs - now) / 1000,
);
return NextResponse.json(
{ error: "Too many requests" },
{
status: 429,
headers: { "Retry-After": String(retryAfter) },
},
);
}
entry.timestamps.push(now);
return null;
}

View file

@ -9,6 +9,12 @@ export function middleware(request: NextRequest) {
return NextResponse.next();
}
// Let API requests with Bearer token pass through (mobile clients)
const authHeader = request.headers.get('authorization');
if (pathname.startsWith('/api/') && authHeader?.startsWith('Bearer ')) {
return NextResponse.next();
}
// Protected routes: check for Logto session cookie
// The Logto SDK stores session data in a cookie named `logto_<appId>`
const hasSession = request.cookies.getAll().some(
@ -19,9 +25,9 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/auth', request.url));
}
if (hasSession && pathname === '/auth') {
return NextResponse.redirect(new URL('/', request.url));
}
// Don't redirect /auth → / based on cookie alone
// The cookie may exist but the session may be invalid
// Let the auth page handle this via getLogtoContext
return NextResponse.next();
}