Compare commits

..

No commits in common. "master" and "v1.5.0" have entirely different histories.

77 changed files with 599 additions and 3332 deletions

View file

@ -1,76 +0,0 @@
---
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` (et `npm install` pour synchroniser le lockfile)
4. Commit : `chore: bump versionCode to <N>` (ou `chore: bump version to X.Y.Z (versionCode N)` si version aussi bumpee)
5. Push commit sur master, puis tag `vX.Y.Z` et `git push origin vX.Y.Z`
6. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive`
7. Quand le build est termine : telecharger l'APK, creer la release Forgejo, attacher l'APK
## Upload de l'APK — pattern resilient
```bash
# 1. Telecharger
APK_URL=$(npx --yes eas-cli build:view <build_id> --json | jq -r '.artifacts.buildUrl')
curl -L -o "/tmp/simpl-liste-vX.Y.Z.apk" "$APK_URL"
# 2. Creer la release
TOKEN=$(cat ~/.forgejo-token)
RELEASE_ID=$(curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases" \
-d '{"tag_name":"vX.Y.Z","name":"vX.Y.Z","body":"...","draft":false,"prerelease":false}' \
| jq -r '.id')
# 3. Attacher l'APK — RETRY sur 502/504 (variance reseau, pas un probleme infra)
for i in 1 2 3 4 5; do
http=$(curl -X POST -H "Authorization: token $TOKEN" \
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases/$RELEASE_ID/assets?name=simpl-liste-vX.Y.Z.apk" \
-F "attachment=@/tmp/simpl-liste-vX.Y.Z.apk" \
-o /tmp/asset-resp.json -w "%{http_code}" --max-time 300)
case "$http" in
201) echo "Upload OK"; break;;
502|504) echo "Attempt $i: gateway timeout, retry"; sleep 5;;
*) echo "Unexpected: HTTP $http"; cat /tmp/asset-resp.json; break;;
esac
done
```
## 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
## Gotcha — Upload APK : 502/504 transitoire
**Symptome** : `curl -X POST ... -F "attachment=@<apk>"` retourne HTTP 502 ou 504 apres ~60s. La progression curl montre upload coupe a 50-90% de progression. Affecte typiquement les APK ~90MB+.
**Cause** : Traefik (devant Forgejo via Coolify) a un timeout de gateway de 60s sur la fin de requete. Quand la vitesse upload tombe sous ~1.5 MB/s (variance reseau classique sur connexions residentielles), un APK de 92 MB depasse le seuil et la requete est coupee. **Ce n'est pas un probleme de config infra** — c'est purement la vitesse upload du client a un instant donne.
**Fix** : retry. La 2e ou 3e tentative passe quand la bande passante remonte (j'ai mesure 1.3 MB/s puis 17 MB/s a quelques minutes d'intervalle). Le pattern `for i in 1..5` ci-dessus est suffisant — pas besoin de toucher a Traefik ni Forgejo.
**Verification que c'est bien le bon probleme** :
- L'APK precedent (~97 MB) a ete uploade avec succes les semaines passees → infra OK
- Un fichier de test de 11 octets sur la meme release passe en 0.1s → API OK
- HTTP 502 ou 504 apres 60s exactement → timeout gateway, pas erreur de logique
- Vitesse upload curl reportee < 1.5 MB/s confirme la cause
**Path API a connaitre** :
- Creer release : `POST /repos/{owner}/{repo}/releases` (renvoie `.id`)
- Attacher asset : `POST /repos/{owner}/{repo}/releases/{release_id}/assets?name=<filename>` (multipart `attachment`)
- Supprimer asset : `DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}`**inclure le release_id dans le path**, pas seulement l'asset id, sinon HTTP 404

View file

@ -1,50 +0,0 @@
# SECURITY — simpl-liste
Rapport d'etat securite. Scanne quotidiennement par `defenseur-simpl-liste`
(04:15 UTC) dans `~/claude-code/defenseurs/`.
## CVE resolues
### 2026-04-24 — Overrides `@xmldom/xmldom` et `uuid`
Correction via `overrides` dans `package.json`. Aucune n'etait exploitable a
runtime dans l'APK : chaine concernee est build-time (iOS build via xcode,
Expo CLI, plist serialization) ou dev-tunnel (ngrok).
| CVE | Severite | Package | Fix version | Etat |
|---|---|---|---|---|
| GHSA-2v35-w6hq-6mfw | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-f6ww-3ggp-fr8h | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-x6wf-f3px-wcqx | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-j759-j44w-7fr8 | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-w5hq-g745-h8pq | MEDIUM | uuid | ^11.0.0 | Mitigation partielle (voir ci-dessous) |
## CVE residuelles
### GHSA-w5hq-g745-h8pq — `uuid` buffer bounds check
**Status** : mitigation partielle. L'advisory npm flag `uuid <14.0.0` meme apres
override a `^11.0.0`. Bug concerne `uuid.v3() / v5() / v6()` quand `buf` param
est fourni. Les consommateurs transitifs (`xcode`, `@expo/ngrok`) utilisent
uniquement `uuid.v4()` — donc **pas de code path vulnerable atteint en pratique**.
Non bump vers `^14.0.0` car ESM-only (casserait les imports CJS de xcode et
ngrok dans la chaine build iOS). Voir spec decision D3 pour details.
**Impact** : cascade via 18 advisories transitives (uuid → xcode/ngrok → expo/* → ...),
toutes remontant a la meme racine. Non-bloquant pour la production.
**Re-evaluation** : lors du prochain upgrade Expo SDK (quand xcode + ngrok
passeront a une version compatible avec `uuid@^14` ou retireront uuid).
## Procedure de scan manuel
```bash
cd ~/claude-code/defenseurs && npx tsx src/defenseur.ts defenseur-simpl-liste
```
## Politique de review
- **HIGH** : fix immediat via PR.
- **MEDIUM** : triage sous 7 jours, fix si exploitable.
- **LOW** : accepte tel quel, documente ici.

View file

@ -1,22 +0,0 @@
# STATE — simpl-liste
> Derniere MAJ : 2026-05-10 (par fix-issue #87)
## Position actuelle
Version 1.6.1 (versionCode 13). Remediation vulnerabilites du defenseur en cours
via overrides `@xmldom/xmldom@^0.8.13` et `uuid@^11.0.0` dans `package.json`
(overnight 2026-04-24). 4 CVE HIGH xmldom nettoyees ; 1 advisory uuid residuelle
non-exploitable en pratique (details dans `SECURITY.md`).
## Decisions recentes
- 2026-05-10 : Aligne 6 patches Expo SDK 54 via expo install --fix — expo-doctor 17/17 (ref #87)
- 2026-05-08 : Defenseur rerun confirme 0 findings — overrides existants suffisent, pas de PR necessaire (ref #81)
- 2026-04-24 : overrides xmldom + uuid (spec `spec-decisions-vuln-simpl-liste.md`) — PRs #77, #78, #79 (pending-human).
- 2026-04-23 : PR #71 merged — fix widget render-optimiste + timing instrumentation.
- 2026-04-18 : archive milestone `spec-simpl-liste-web` (12/12 done).
## Blockers actifs
Aucun.

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.6.4",
"version": "1.4.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",
@ -24,7 +24,7 @@
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"versionCode": 16
"versionCode": 7
},
"plugins": [
"expo-router",
@ -74,9 +74,7 @@
}
]
}
],
"expo-secure-store",
"expo-web-browser"
]
],
"experiments": {
"typedRoutes": true

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw } from 'lucide-react-native';
import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist';
@ -44,8 +44,6 @@ export default function InboxScreen() {
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const isDraggingRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
@ -72,31 +70,6 @@ export default function InboxScreen() {
return () => clearInterval(interval);
}, [loadTasks]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 800,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
try {
await loadTasks();
} finally {
setRefreshing(false);
spinAnim.stopAnimation();
}
}, [loadTasks, refreshing, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const handleToggle = async (id: string) => {
await toggleComplete(id);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@ -191,11 +164,6 @@ export default function InboxScreen() {
</View>
) : (
<View className={`flex-row items-center justify-end border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<Pressable onPress={handleRefresh} disabled={refreshing} className="mr-3 p-1">
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Animated.View>
</Pressable>
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>

View file

@ -5,14 +5,12 @@ 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, initialMerge, initialReset } from '@/src/services/syncClient';
import { fullSync } from '@/src/services/syncClient';
import i18n from '@/src/i18n';
type ThemeMode = 'light' | 'dark' | 'system';
@ -41,7 +39,6 @@ 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();
@ -102,52 +99,12 @@ export default function SettingsScreen() {
]);
};
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 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 handleSignOut = () => {
@ -156,12 +113,7 @@ export default function SettingsScreen() {
{
text: t('sync.signOut'),
style: 'destructive',
onPress: async () => {
try {
await logtoSignOut(postSignOutRedirectUri);
} catch {
// Sign-out may fail if session expired, that's OK
}
onPress: () => {
setSyncEnabled(false);
setUserId(null);
setLastSyncAt(null);
@ -171,46 +123,6 @@ 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,7 +8,6 @@ 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';
@ -16,8 +15,6 @@ 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';
@ -59,6 +56,12 @@ 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;
@ -74,38 +77,9 @@ 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) return;
if (!syncEnabled || !migrationsReady) return;
// Initial sync
fullSync().then(() => cleanOutbox()).catch(() => {});
@ -127,7 +101,11 @@ function AppContent() {
clearInterval(interval);
subscription.remove();
};
}, [syncEnabled]);
}, [syncEnabled, migrationsReady]);
if (!fontsLoaded || !migrationsReady) {
return null;
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>

View file

@ -1,8 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw,
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
@ -61,8 +61,6 @@ export default function ListDetailScreen() {
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const isDraggingRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
@ -97,31 +95,6 @@ export default function ListDetailScreen() {
return () => clearInterval(interval);
}, [loadData]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 800,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
try {
await loadData();
} finally {
setRefreshing(false);
spinAnim.stopAnimation();
}
}, [loadData, refreshing, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const handleToggle = async (taskId: string) => {
await toggleComplete(taskId);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@ -219,11 +192,6 @@ export default function ListDetailScreen() {
</Text>
</View>
<View className="flex-row items-center">
<Pressable onPress={handleRefresh} disabled={refreshing} className="mr-3 p-1">
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Animated.View>
</Pressable>
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>

View file

@ -54,7 +54,6 @@ type TaskData = {
priority: number;
dueDate: Date | null;
listId: string;
parentId: string | null;
recurrence: string | null;
};
@ -401,71 +400,67 @@ export default function TaskDetailScreen() {
</>
)}
{/* Subtasks — only for root tasks (not subtasks themselves) */}
{!task?.parentId && (
<>
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => editingSubtaskId === sub.id ? undefined : handleToggleSubtask(sub.id)}
onLongPress={() => handleEditSubtask(sub)}
className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
{editingSubtaskId === sub.id ? (
<TextInput
value={editingTitle}
onChangeText={setEditingTitle}
onSubmitEditing={handleSaveSubtaskEdit}
onBlur={handleSaveSubtaskEdit}
autoFocus
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
) : (
<Text
className={`flex-1 text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
</Text>
)}
<Pressable
onPress={() => handleDeleteSubtask(sub.id)}
className="ml-2 p-1.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<X size={16} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
</Pressable>
</Pressable>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
{/* Subtasks */}
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => editingSubtaskId === sub.id ? undefined : handleToggleSubtask(sub.id)}
onLongPress={() => handleEditSubtask(sub)}
className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
{editingSubtaskId === sub.id ? (
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
value={editingTitle}
onChangeText={setEditingTitle}
onSubmitEditing={handleSaveSubtaskEdit}
onBlur={handleSaveSubtaskEdit}
autoFocus
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
</>
)}
) : (
<Text
className={`flex-1 text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
</Text>
)}
<Pressable
onPress={() => handleDeleteSubtask(sub.id)}
className="ml-2 p-1.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<X size={16} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
</Pressable>
</Pressable>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
<View style={{ height: 32 }} />
</KeyboardAwareScrollView>

634
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,9 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.6.4",
"version": "1.4.0",
"scripts": {
"start": "expo start",
"test": "node tests/smoke.test.cjs",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
@ -13,31 +12,28 @@
"@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.2",
"expo": "~54.0.34",
"expo-auth-session": "~7.0.11",
"drizzle-orm": "^0.45.1",
"expo": "~54.0.33",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-intent-launcher": "~13.0.8",
"expo-linking": "~8.0.12",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.17",
"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",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.11",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.13",
"lucide-react-native": "^0.575.0",
"nativewind": "^4.2.2",
@ -65,10 +61,7 @@
"typescript": "~5.9.2"
},
"overrides": {
"esbuild": "^0.25.0",
"@xmldom/xmldom": "^0.8.13",
"uuid": "^11.1.1",
"postcss": "^8.5.10"
"esbuild": "^0.25.0"
},
"private": true
}

View file

@ -1,603 +0,0 @@
# Spec — Simpl-Liste Web
> Date: 2026-04-06
> Projet: simpl-liste
> Statut: Draft
> Dependance: Logto IdP (deploye et operationnel)
## Contexte
Simpl-Liste est actuellement une app mobile React Native/Expo avec stockage SQLite local (offline-first, sans compte). L'objectif est de rendre l'application disponible via le web, integree au systeme d'authentification Compte Maximus (Logto), avec synchronisation bidirectionnelle entre le mobile et le web.
L'app mobile continue de fonctionner sans compte. Le login debloque la sync ; il reste optionnel.
## Objectif
Permettre aux utilisateurs connectes avec un Compte Maximus de gerer leurs listes et taches depuis `liste.lacompagniemaximus.com`, avec sync hybride : temps reel sur le web (WebSocket), polling sur le mobile. Parite fonctionnelle complete avec le mobile (hors widgets, notifications push, et sync calendrier).
## Scope
### IN
- Frontend web Next.js App Router dans `simpl-liste/web/`
- API REST backend (route handlers Next.js) pour CRUD listes, taches, tags, sous-taches
- WebSocket server pour notifications temps reel sur le web
- Schema PostgreSQL (`sl_` prefix) avec Drizzle ORM (`drizzle-orm/pg-core`)
- Auth via Logto (Compte Maximus) — SDK `@logto/next`
- Sync hybride : WebSocket web + polling mobile
- Login optionnel dans l'app mobile avec choix de migration des donnees locales
- Types TypeScript partages entre mobile et web (`src/shared/`)
- i18n FR/EN (francais par defaut)
- Dark mode (light/dark/system)
- Responsive design (utilisable sur mobile)
- Deploiement sur Coolify (`liste.lacompagniemaximus.com`)
### OUT (explicitement exclu)
- Widgets Android (specifique mobile)
- Notifications push (specifique mobile)
- Sync calendrier systeme (specifique mobile via expo-calendar)
- Export ICS depuis le web (future phase)
- Mode offline pour le web (connexion requise)
- Partage de listes entre utilisateurs (future phase)
- App iOS (future phase)
## Design
### Architecture globale
```
simpl-liste/
├── app/ # App mobile React Native (existant)
├── src/ # Code mobile (existant)
│ ├── shared/ # NOUVEAU — types, constantes, logique partagee
│ │ ├── types.ts # Task, List, Tag, TaskFilters, etc.
│ │ ├── colors.ts # Palette (extrait de theme/colors.ts)
│ │ ├── priority.ts # Helpers priorite (extrait de lib/priority.ts)
│ │ └── recurrence.ts # Calcul recurrence (extrait de lib/recurrence.ts)
│ └── ...
└── web/ # NOUVEAU — App Next.js
├── package.json
├── next.config.ts
├── server.ts # Custom server (Next.js + WebSocket)
├── Dockerfile
├── src/db/
│ ├── schema.ts # Schema PostgreSQL Drizzle (sl_ tables)
│ ├── client.ts # Connexion pg + drizzle instance
│ └── migrations/ # Migrations SQL (drizzle-kit generate)
├── src/
│ ├── app/ # App Router (pages, layouts, API routes)
│ │ ├── layout.tsx
│ │ ├── page.tsx # Redirect vers /lists ou /auth
│ │ ├── auth/ # Login via Logto
│ │ ├── api/ # Route handlers REST
│ │ │ ├── lists/
│ │ │ ├── tasks/
│ │ │ ├── tags/
│ │ │ └── sync/
│ │ └── (app)/ # Pages authentifiees
│ │ ├── layout.tsx # Sidebar listes + main area
│ │ ├── inbox/
│ │ └── lists/[id]/
│ ├── lib/
│ │ ├── auth.ts # Logto middleware + session
│ │ ├── db.ts # Re-export drizzle client
│ │ └── ws.ts # WebSocket server + broadcast
│ ├── components/ # Composants React web
│ └── i18n/ # next-intl ou i18next
└── tailwind.config.ts # Palette partagee depuis src/shared/colors.ts
```
### ORM unifie — Drizzle partout
Le projet utilise **Drizzle ORM** sur les deux plateformes :
- **Mobile** : `drizzle-orm/sqlite-core` + `expo-sqlite` (existant)
- **Web/serveur** : `drizzle-orm/pg-core` + `pg` (nouveau)
Un seul ORM a maitriser, un seul outil de migration (`drizzle-kit`), pas de codegen (contrairement a Prisma). Les schemas SQLite et PostgreSQL partagent la meme structure de colonnes — les types inferes par Drizzle (`$inferSelect`) sont directement compatibles.
Le dossier `src/shared/types.ts` exporte les types metier communs (filtres, tri, recurrence, priorites). Les types des entites (Task, List, Tag) sont inferes depuis les schemas Drizzle respectifs — pas besoin d'une couche de mapping manuelle.
```typescript
// src/shared/types.ts — types partages (filtres, enums, helpers)
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
export type Priority = 0 | 1 | 2 | 3;
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
export type SortOrder = 'asc' | 'desc';
export type FilterCompleted = 'all' | 'active' | 'completed';
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
export interface TaskFilters { sortBy: SortBy; sortOrder: SortOrder; /* ... */ }
// Les types des entites sont inferes depuis les schemas Drizzle de chaque plateforme :
// Mobile : typeof tasks.$inferSelect (depuis src/db/schema.ts, sqlite-core)
// Web : typeof slTasks.$inferSelect (depuis web/src/db/schema.ts, pg-core)
```
### Identite utilisateur — Compte Maximus
Le `userId` stocke dans les tables PostgreSQL est le **`sub` claim du JWT Logto**. Ce choix est delibere : Logto est le systeme d'identite centralise de La Compagnie Maximus. Toutes les apps actuelles et futures (la-suite-booking, famille-website, etc.) migreront vers Logto comme IdP unique. Utiliser le `sub` comme identifiant commun garantit la compatibilite cross-app et permettra un futur dashboard Compte Maximus unifie.
### Schema PostgreSQL (Drizzle pg-core)
Base sur le schema SQLite existant (`src/db/schema.ts`), avec ajout de `userId` (= `sub` Logto) pour l'isolation multi-utilisateur et `deletedAt` pour le soft-delete (sync). Structure quasi identique au schema mobile — meme ORM, memes conventions.
```typescript
// web/src/db/schema.ts
import { pgTable, uuid, text, integer, boolean, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';
export const slLists = pgTable('sl_lists', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
color: text('color'),
icon: text('icon'),
position: integer('position').notNull().default(0),
isInbox: boolean('is_inbox').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_lists_user').on(table.userId),
]);
export const slTasks = pgTable('sl_tasks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
title: text('title').notNull(),
notes: text('notes'),
completed: boolean('completed').notNull().default(false),
completedAt: timestamp('completed_at', { withTimezone: true }),
priority: integer('priority').notNull().default(0), // 0=none, 1=low, 2=medium, 3=high
dueDate: timestamp('due_date', { withTimezone: true }),
listId: uuid('list_id').notNull().references(() => slLists.id, { onDelete: 'cascade' }),
parentId: uuid('parent_id').references((): any => slTasks.id, { onDelete: 'cascade' }),
position: integer('position').notNull().default(0),
recurrence: text('recurrence'), // 'daily'|'weekly'|'monthly'|'yearly'
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_tasks_user').on(table.userId),
index('idx_sl_tasks_list').on(table.listId),
index('idx_sl_tasks_parent').on(table.parentId),
]);
export const slTags = pgTable('sl_tags', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
color: text('color').notNull().default('#4A90A4'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_tags_user').on(table.userId),
]);
export const slTaskTags = pgTable('sl_task_tags', {
taskId: uuid('task_id').notNull().references(() => slTasks.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id').notNull().references(() => slTags.id, { onDelete: 'cascade' }),
}, (table) => [
primaryKey({ columns: [table.taskId, table.tagId] }),
]);
```
Notes :
- `deletedAt` (soft-delete) sur lists, tasks, tags — necessaire pour la sync (les suppressions doivent etre propagees aux clients)
- `calendar_event_id` omis (specifique mobile)
- `updatedAt` sert de vecteur de version pour le last-write-wins
- La table `sl_tags` n'a pas de `updatedAt` dans le schema SQLite mobile — a ajouter cote mobile pour la sync
- Le schema PostgreSQL est structurellement identique au schema SQLite mobile, avec `userId` et `deletedAt` en plus — memes noms de colonnes, memes types logiques
### API REST
Base URL: `https://liste.lacompagniemaximus.com/api`
Auth: Session Logto (cookie) pour le web, Bearer JWT pour le mobile
#### Listes
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/lists` | Toutes les listes de l'utilisateur |
| POST | `/api/lists` | Creer une liste |
| PUT | `/api/lists/:id` | Modifier une liste |
| DELETE | `/api/lists/:id` | Soft-delete (cascade taches) |
| PUT | `/api/lists/reorder` | Reordonner (batch positions) |
#### Taches
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/lists/:listId/tasks` | Taches d'une liste (filtres: completed, priority, dueDate, tags) |
| GET | `/api/tasks/:id/subtasks` | Sous-taches |
| POST | `/api/tasks` | Creer une tache |
| PUT | `/api/tasks/:id` | Modifier (titre, notes, completed, priorite, dueDate, listId, position, recurrence) |
| DELETE | `/api/tasks/:id` | Soft-delete (cascade sous-taches) |
| PUT | `/api/tasks/reorder` | Reordonner dans une liste |
#### Tags
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/tags` | Tous les tags de l'utilisateur |
| POST | `/api/tags` | Creer un tag |
| PUT | `/api/tags/:id` | Modifier un tag |
| DELETE | `/api/tags/:id` | Soft-delete |
| POST | `/api/tasks/:id/tags` | Assigner des tags a une tache |
| DELETE | `/api/tasks/:taskId/tags/:tagId` | Retirer un tag |
#### Sync (mobile)
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/sync?since=<ISO timestamp>` | Changements serveur depuis timestamp (inclut soft-deletes) |
| POST | `/api/sync` | Push batch de changements locaux (avec idempotency keys) |
#### WebSocket (web)
| Methode | Endpoint | Description |
|---------|----------|-------------|
| POST | `/api/ws-ticket` | Generer un ticket ephemere (nonce, TTL 30s) pour le handshake WS |
| WS | `/ws?ticket=<nonce>` | Connexion WebSocket (ticket a usage unique) |
### Sync Strategy — Hybride
#### Web : WebSocket temps reel
- Custom server Next.js (`server.ts`) qui attache un serveur `ws` sur le meme port HTTP (path `/ws`)
- A chaque mutation via l'API, le serveur broadcast un message aux WebSocket connectes du meme `userId`
- Le client web ecoute et rafraichit les donnees affectees (pas de re-fetch complet, notification granulaire par entity type + id)
- Reconnexion automatique avec backoff exponentiel cote client
- Heartbeat toutes les 30s pour detecter les connexions mortes
```typescript
// Message WebSocket — notification seulement, pas de payload (securite)
type WsMessage =
| { type: 'sync'; entity: 'list' | 'task' | 'tag'; action: 'create' | 'update' | 'delete'; id: string }
| { type: 'auth_expired' } // session expiree, le client doit se reconnecter
```
#### Mobile : polling periodique
- Sync au lancement de l'app (si connecte)
- Sync toutes les 2 minutes en foreground
- Sync au retour du background (app state change)
- Indicateur visuel de statut sync (icone dans le header)
#### Resolution de conflits
- **Last-write-wins** base sur `updatedAt` — le timestamp le plus recent gagne
- En cas d'egalite exacte (improbable), le serveur gagne
- Les suppressions (soft-delete) sont prioritaires : si un client supprime et l'autre modifie, la suppression gagne
#### Flux de sync mobile
1. Le mobile envoie `POST /api/sync` avec tous les changements locaux depuis `lastSyncAt`
2. Le serveur applique les changements (LWW), retourne les conflits resolus
3. Le mobile fait `GET /api/sync?since=lastSyncAt` pour recuperer les changements serveur
4. Le mobile applique les changements serveur dans SQLite
5. `lastSyncAt` est mis a jour dans AsyncStorage
#### Premiere connexion — Migration des donnees locales
Au premier login dans l'app mobile, une alerte propose :
1. **"Fusionner mes taches"** — Upload toutes les donnees locales vers le serveur, puis sync bidirectionnel
2. **"Repartir du serveur"** — Ecrase les donnees locales avec celles du serveur (si le compte a deja des donnees web)
**Reconciliation de l'Inbox** : L'Inbox mobile a un ID hardcode (`00000000-0000-0000-0000-000000000001`). Le serveur cree une Inbox avec un UUID genere. Lors du merge :
1. Le serveur cree l'Inbox du user si elle n'existe pas
2. Le mobile remap l'ID hardcode vers l'ID Inbox serveur dans toutes les taches locales
3. Push les taches avec le nouvel ID
4. Mettre a jour l'ID Inbox local pour matcher le serveur
#### Outbox pattern — file d'attente locale
Les changements locaux sont d'abord ecrits dans une table SQLite `sync_outbox` avant d'etre envoyes au serveur :
```sql
CREATE TABLE sync_outbox (
id TEXT PRIMARY KEY, -- UUID (= idempotency key)
entity_type TEXT NOT NULL, -- 'list' | 'task' | 'tag' | 'task_tag'
entity_id TEXT NOT NULL, -- ID de l'entite modifiee
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
payload TEXT NOT NULL, -- JSON de l'operation
created_at TEXT NOT NULL, -- ISO 8601
synced_at TEXT -- NULL tant que pas sync, ISO 8601 apres
);
```
Flux :
1. Chaque mutation locale ecrit dans la table metier ET dans `sync_outbox`
2. Le sync service lit les entries `WHERE synced_at IS NULL`, les envoie en batch via `POST /api/sync`
3. Les entries synced sont marquees (`synced_at = now()`)
4. Les entries de plus de 7 jours sont purgees
5. Si le serveur est inaccessible, l'outbox s'accumule — les changements ne sont jamais perdus
### Auth
#### Web (Next.js)
- SDK `@logto/next` pour l'auth server-side
- Middleware Next.js qui protege toutes les routes sous `/(app)/`
- Session cookie securisee (HttpOnly, SameSite=Strict)
- Extraction du `userId` depuis les claims JWT Logto
- Verification optionnelle du claim `apps.simpl-liste` pour l'acces
#### Mobile (React Native)
- SDK `@logto/rn` pour le flow OAuth2/OIDC
- Bouton "Se connecter" dans Settings > Compte
- Token JWT stocke securise (expo-secure-store)
- Le token est envoye en header `Authorization: Bearer <jwt>` pour les appels API
- Mode deconnecte : l'app fonctionne normalement sans token, la sync est desactivee
### Frontend Web — UX
#### Layout principal
```
┌─────────────────────────────────────────────────┐
│ Simpl-Liste [FR] [🌙] [👤] │
├──────────┬──────────────────────────────────────┤
│ │ │
│ 📥 Inbox │ ☐ Acheter du lait ⚡ Haute │
│ 📋 Courses│ ☐ Appeler le dentiste 📅 Lun │
│ 🏠 Maison │ ☑ Faire le menage ✓ Fait │
│ + Liste │ ☐ Reparer le robinet │
│ │ │
│ Tags │ [+ Nouvelle tache] │
│ 🔵 Urgent│ │
│ 🟢 Perso │ Filtres: [Actives ▾] [Priorite ▾] │
│ │ Tri: [Position ▾] │
├──────────┴──────────────────────────────────────┤
│ ● Connecte — Sync OK │
└─────────────────────────────────────────────────┘
```
- **Sidebar gauche** : listes (avec couleur/icone), section tags, bouton + pour creer
- **Zone principale** : taches de la liste selectionnee, avec filtres et tri en haut
- **Barre d'etat** : statut de connexion et derniere sync
- Edition inline des taches (titre, completion, priorite) — modal pour les details complets (notes, date, tags, sous-taches, recurrence)
- Responsive : sidebar se replie en hamburger menu sur mobile web
### Securite
#### Transport et authentification
- TLS obligatoire (HTTPS/WSS via Caddy) — jamais de connexion non chiffree
- Toutes les requetes API authentifiees (session cookie web / JWT Bearer mobile)
- Session cookie web : `HttpOnly`, `Secure`, `SameSite=Strict`
- JWT mobile : access token court (15 min) + refresh token avec rotation (chaque refresh invalide l'ancien)
- Validation JWT systematique sur chaque requete : signature, `exp`, `aud`, `iss`
#### Stockage des tokens mobile
- Adaptateur de stockage custom pour `@logto/rn` basé sur `expo-secure-store` (Keychain iOS / Keystore Android)
- Ne JAMAIS stocker de tokens dans AsyncStorage (non chiffre, lisible sur appareil roote)
- Ne pas inclure de donnees sensibles dans le payload JWT (le payload est encode en base64, pas chiffre)
#### WebSocket — Anti-CSWSH (Cross-Site WebSocket Hijacking)
- Authentification par **ticket ephemere** (pas par cookie) :
1. Le client appelle `POST /api/ws-ticket` (authentifie par session)
2. Le serveur genere un nonce a usage unique (TTL 30s), stocke en memoire
3. Le client se connecte a `wss://host/ws?ticket=<nonce>`
4. Le serveur valide et invalide le ticket au handshake (usage unique)
- Validation du header `Origin` contre une allowlist (`liste.lacompagniemaximus.com`)
- Re-validation de la session toutes les 15 minutes — fermer la connexion si expiree, envoyer `{ type: 'auth_expired' }` au client
- Ne pas logger les query params sur la route `/ws` (eviter fuite du ticket dans les access logs)
#### Isolation des donnees et autorisation
- Isolation par `userId` sur chaque requete (`WHERE userId = ?`)
- **BOLA prevention sur le sync batch** : pour chaque operation dans `POST /api/sync`, verifier que l'entite appartient au `userId` (`WHERE id = ? AND userId = ?`). Rejeter le batch entier si une seule verification echoue.
- Schemas Zod **strict** sur chaque endpoint — whitelist explicite des champs modifiables
- Ne jamais permettre au client de modifier `userId`, `createdAt`, `deletedAt` via l'API
#### Anti-replay sur les endpoints sync
- **Idempotency keys** : chaque operation dans le batch porte un UUID genere par le client. Le serveur stocke les IDs traites (TTL 24h) et retourne le meme resultat si resoumis.
- **Fenetre temporelle** : rejeter les requetes dont le timestamp depasse +/- 5 minutes
#### Rate limiting granulaire
| Endpoint | Limite par user | Raison |
|----------|----------------|--------|
| `POST /api/sync` | 10/min | Operation lourde (batch) |
| `POST /api/ws-ticket` | 10/min | Prevenir brute-force de tickets |
| `POST /api/lists`, `POST /api/tasks`, `POST /api/tags` | 30/min | Creation |
| `GET /api/*` | 200/min | Lecture |
| Connexions WebSocket | 5/min | Prevenir flood de reconnexions |
#### Messages WebSocket
- Envoyer seulement le type d'entite + id dans les messages WS (pas le contenu des taches)
- Le client re-fetch les donnees modifiees via l'API REST (securisee)
- Evite la fuite de donnees si la connexion WS est compromise
#### Soft-delete et retention
- Purge automatique des enregistrements soft-deleted apres 30 jours (cron job)
- Les clients qui n'ont pas sync depuis 30+ jours effectuent un full sync au lieu d'un delta
- Politique de retention documentee dans les conditions d'utilisation
#### Input validation
- Validation/sanitization (zod) sur tous les champs texte
- Protection XSS : echapper le contenu utilisateur a l'affichage (React le fait par defaut)
- CSRF protection sur les mutations (cookie `SameSite=Strict`)
## Plan de travail
### Issue 1 — Types partages et refactoring mobile [type:task]
Dependances : aucune
- [ ] Creer `src/shared/types.ts` — exporter Task, List, Tag, TaskFilters, SortBy, etc.
- [ ] Creer `src/shared/colors.ts` — extraire la palette de `theme/colors.ts`
- [ ] Creer `src/shared/priority.ts` — extraire de `lib/priority.ts`
- [ ] Creer `src/shared/recurrence.ts` — extraire de `lib/recurrence.ts`
- [ ] Refactorer le code mobile pour importer depuis `shared/`
- [ ] Ajouter `updatedAt` au schema `tags` dans le mobile (migration Drizzle)
### Issue 2 — Setup projet web + schema PostgreSQL [type:task]
Dependances : aucune (parallele avec Issue 1)
- [ ] Init Next.js App Router dans `web/` avec TypeScript, Tailwind, Drizzle ORM (`drizzle-orm/pg-core` + `pg`)
- [ ] Schema Drizzle PostgreSQL (sl_lists, sl_tasks, sl_tags, sl_task_tags) — userId = `sub` Logto
- [ ] Migration initiale + seed (inbox par defaut par user)
- [ ] Endpoint `/api/health` (status API, latence DB, connexions WS actives)
- [ ] Dockerfile + config Coolify pour `liste.lacompagniemaximus.com` (verifier support WS dans Caddy)
- [ ] Configurer Logto app pour le web (redirect URIs, etc.)
### Issue 3 — Auth web + middleware [type:feature]
Dependances : Issue 2
- [ ] Integrer `@logto/next` (sign-in, sign-out, callback)
- [ ] Middleware Next.js : proteger `/(app)/`, extraire userId
- [ ] Page de login (`/auth`) avec redirect vers Logto
- [ ] Gestion de session (cookie securise)
### Issue 4 — API REST backend [type:feature]
Dependances : Issue 2, Issue 3
- [ ] Middleware auth sur `/api/*` (session cookie + Bearer JWT avec validation signature/exp/aud/iss)
- [ ] Endpoints CRUD listes (avec soft-delete)
- [ ] Endpoints CRUD taches (avec filtres, tri, sous-taches, soft-delete)
- [ ] Endpoints CRUD tags + assignation (avec soft-delete)
- [ ] Endpoints sync (GET since + POST batch avec idempotency keys)
- [ ] Verification BOLA par entite sur le batch sync (`WHERE id = ? AND userId = ?`)
- [ ] Schemas Zod strict sur chaque endpoint (whitelist de champs, rejeter champs inconnus)
- [ ] Rate limiting granulaire (sync 10/min, creation 30/min, lecture 200/min)
- [ ] Endpoint `POST /api/ws-ticket` (nonce ephemere TTL 30s, usage unique)
- [ ] Tests API (optionnel mais recommande)
### Issue 5 — WebSocket server [type:feature]
Dependances : Issue 4
- [ ] Custom server (`server.ts`) : Next.js + `ws` sur le meme port
- [ ] Auth WebSocket par ticket ephemere (valider et invalider le nonce au handshake)
- [ ] Validation du header `Origin` contre l'allowlist
- [ ] Broadcast par userId sur chaque mutation API (type + id seulement, pas de payload)
- [ ] Re-validation de session toutes les 15 min (fermer si expiree + message `auth_expired`)
- [ ] Heartbeat 30s + reconnexion auto cote client avec backoff exponentiel
- [ ] Ne pas logger les query params sur la route `/ws`
- [ ] Mettre a jour le Dockerfile pour utiliser le custom server
### Issue 6 — Frontend web [type:feature]
Dependances : Issue 4, Issue 5
- [ ] Layout principal (sidebar listes + zone taches)
- [ ] CRUD listes (creer, renommer, supprimer, couleur/icone, reordonner)
- [ ] CRUD taches (creer, editer inline, completer, supprimer, priorite, date, notes)
- [ ] Sous-taches (affichage imbrique, creer/editer)
- [ ] Tags (creer, assigner, filtrer)
- [ ] Filtres et tri
- [ ] Recurrence (affichage, creation, auto-recurrence au complete)
- [ ] Integration WebSocket (mise a jour temps reel)
- [ ] Dark mode (light/dark/system)
- [ ] i18n FR/EN
- [ ] Responsive design
- [ ] Barre de statut sync
### Issue 7 — Sync mobile [type:feature]
Dependances : Issue 1, Issue 4
- [ ] Integrer `@logto/rn` avec adaptateur `expo-secure-store` (ne pas utiliser AsyncStorage pour les tokens)
- [ ] Bouton login dans Settings > Compte
- [ ] Access token court (15 min) + refresh token avec rotation automatique
- [ ] Intercepteur HTTP pour refresh transparent (capturer 401, rafraichir, relancer)
- [ ] Table `sync_outbox` dans SQLite (migration Drizzle) — outbox pattern
- [ ] Client sync : push outbox entries + pull changes avec idempotency keys
- [ ] Reconciliation Inbox au premier merge (remap ID hardcode → ID serveur)
- [ ] Polling periodique (2 min) + sync au lancement + sync au retour foreground
- [ ] Gestion des conflits LWW
- [ ] Ecran de premiere connexion : choix merge local ou reset serveur
- [ ] Indicateur visuel de statut sync (header)
- [ ] Mode degrade si serveur inaccessible (outbox s'accumule, sync au retour)
### Ordre d'execution
```
Issue 1 (Types partages) ──┐
├── Issue 4 (API REST) ── Issue 5 (WebSocket) ─┐
Issue 2 (Setup web + DB) ──┤ ├── Issue 6 (Frontend)
└── Issue 3 (Auth web) ─┘ │
Issue 1 ── Issue 4 ── Issue 7 (Sync mobile) │
```
## Fichiers concernes
| Fichier | Action | Raison |
|---------|--------|--------|
| `web/` | Creer | Nouveau projet Next.js complet |
| `src/shared/types.ts` | Creer | Types TypeScript partages mobile/web |
| `src/shared/colors.ts` | Creer | Palette centralisee |
| `src/shared/priority.ts` | Creer | Helpers priorite partages |
| `src/shared/recurrence.ts` | Creer | Logique recurrence partagee |
| `src/theme/colors.ts` | Modifier | Importer depuis shared/ |
| `src/lib/priority.ts` | Modifier | Importer depuis shared/ |
| `src/lib/recurrence.ts` | Modifier | Importer depuis shared/ |
| `src/db/schema.ts` | Modifier | Ajouter updatedAt aux tags |
| `src/db/schema.ts` | Modifier | Ajouter table `sync_outbox` |
| `src/db/repository/tasks.ts` | Modifier | Ecrire dans l'outbox a chaque mutation (si connecte) |
| `src/services/syncClient.ts` | Creer | Client sync (push outbox, pull changes, reconciliation Inbox) |
| `src/stores/useSettingsStore.ts` | Modifier | Ajouter syncEnabled, lastSyncAt, userId |
| `app/(tabs)/settings.tsx` | Modifier | Ajouter section Compte (login/logout/sync) |
| `app/_layout.tsx` | Modifier | Init sync polling si connecte |
| `package.json` | Modifier | Ajouter deps auth mobile (@logto/rn, expo-secure-store) |
## Criteres d'acceptation
- [ ] Un utilisateur connecte peut acceder a ses listes depuis liste.lacompagniemaximus.com
- [ ] Les operations CRUD fonctionnent pour les listes, taches, tags et sous-taches sur le web
- [ ] Les modifications sur le web apparaissent en temps reel si deux onglets sont ouverts (WebSocket)
- [ ] Les modifications sur le web apparaissent sur le mobile apres sync polling (et vice versa)
- [ ] L'app mobile continue de fonctionner sans compte (offline-first preserve)
- [ ] Au premier login mobile, l'utilisateur choisit de fusionner ou remplacer ses donnees locales
- [ ] Le site fonctionne en FR et EN, en light et dark mode
- [ ] Le site est responsive (utilisable sur mobile web)
- [ ] Les suppressions se propagent correctement (soft-delete + sync)
- [ ] Le WebSocket se reconnecte automatiquement apres une deconnexion
- [ ] Les tokens mobile sont stockes dans expo-secure-store (pas AsyncStorage)
- [ ] Le handshake WebSocket utilise un ticket ephemere (pas de cookie seul)
- [ ] Les endpoints API rejettent les champs non autorises (userId, createdAt, deletedAt)
- [ ] Le batch sync verifie l'ownership de chaque entite individuellement
- [ ] Les soft-deletes sont purges automatiquement apres 30 jours
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| Conflit de modification simultanee (web + mobile) | Last-write-wins sur `updatedAt`. Acceptable pour usage personnel. |
| Suppression sur un appareil, modification sur l'autre | La suppression gagne (soft-delete prioritaire) |
| Serveur inaccessible depuis le mobile | Mode degrade : l'app fonctionne en local, les changements sont queued et sync au retour |
| WebSocket deconnecte | Reconnexion auto avec backoff exponentiel. Fallback : refresh manuel de la page |
| Premiere sync avec beaucoup de donnees locales | Batch l'upload en chunks de 50 entites pour eviter les timeouts |
| Token JWT expire pendant une session mobile | Refresh token avec rotation via Logto SDK. Si echec, re-login requis |
| Deux listes "Inbox" apres merge (locale + serveur) | Remap ID hardcode mobile vers ID Inbox serveur dans toutes les taches locales avant push |
| Utilisateur supprime son compte | Cascade delete de toutes les donnees sl_* du user dans PostgreSQL |
| Custom server Next.js + Coolify | S'assurer que le Dockerfile utilise `node server.ts` et non `next start`. Verifier le support WebSocket dans Caddy. |
| Changements offline perdus (crash app) | Outbox pattern : les mutations sont persistees dans `sync_outbox` SQLite avant envoi |
| Broadcast WS multi-instance | Architecture single-instance pour la v1. Si scaling necessaire, ajouter Redis pub/sub |
| VPS memoire insuffisante | Surveiller via `/api/health`. Prevoir upgrade 8 Go si migration famille-website en parallele |
| CSWSH (Cross-Site WebSocket Hijacking) | Ticket ephemere a usage unique + validation Origin + cookies SameSite=Strict |
| BOLA sur le batch sync | Verification d'ownership par entite dans chaque operation du batch |
| Replay attack sur POST /api/sync | Idempotency keys par operation + fenetre temporelle +/- 5 min |
| Token mobile sur appareil roote | Stockage dans expo-secure-store (Keychain/Keystore), jamais AsyncStorage |
| Session WS survit a l'expiration auth | Re-validation serveur toutes les 15 min, fermeture si expiree |
| Fuite de donnees via messages WS | Messages WS ne contiennent que type + id, pas le contenu des taches |
| Soft-deletes accumules indefiniment | Purge automatique apres 30 jours, full sync pour clients inactifs |
| Mass assignment via l'API | Schemas Zod strict, whitelist de champs modifiables, `userId`/`createdAt`/`deletedAt` non modifiables |
## Decisions prises
| Question | Decision | Raison |
|----------|----------|--------|
| Stack web | Next.js App Router + Drizzle + PostgreSQL | Fullstack dans un seul projet. Drizzle unifie mobile et serveur. |
| Emplacement du code | `simpl-liste/web/` dans le repo existant | Partage des types via `src/shared/`. Un seul repo a maintenir. |
| ORM serveur | Drizzle (`pg-core`) | Meme ORM que le mobile (`sqlite-core`). Un seul outil a maitriser, schemas compatibles, pas de codegen. |
| Auth | Logto (deploye) | Systeme d'auth centralise La Compagnie Maximus. |
| Sync web | WebSocket (via `ws` + custom server) | Temps reel pour le web. Le VPS a largement les ressources (4 cores, 4 Go libres). |
| Sync mobile | Polling periodique (2 min) | React Native ne maintient pas de WebSocket en background. Polling simple et fiable. |
| Conflits | Last-write-wins sur updatedAt | Simple, suffisant pour un usage personnel avec peu d'appareils. |
| Suppressions | Soft-delete (deletedAt) | Necessaire pour propager les suppressions aux autres clients via sync. |
| Compte requis | Non, optionnel | Preserver l'experience offline-first sans friction. Le login debloque la sync. |
| Migration donnees locales | Choix utilisateur (merge ou reset) | Eviter la perte de donnees et laisser le controle a l'utilisateur. |
| Identite utilisateur | `sub` Logto comme `userId` | Compte Maximus unifie cross-app. Toutes les apps actuelles et futures utilisent le meme identifiant. |
| ORM unifie | Drizzle partout (sqlite-core mobile + pg-core serveur) | Un seul ORM, schemas compatibles, types inferes directement, pas de couche de mapping. |
| Reverse proxy | Caddy (pas Traefik) | Caddy est le reverse proxy en place sur le VPS. Support natif WebSocket. |
| Outbox pattern | Table `sync_outbox` dans SQLite mobile | Les changements offline survivent aux crashes. Idempotency keys integrees. |
| Scaling WS | Single-instance, pas de Redis | Suffisant pour l'usage actuel. Redis pub/sub a ajouter si multi-instance futur. |
| Monitoring | Endpoint `/api/health` | Metriques applicatives (DB, WS, API) en complement de vps-health-api (hardware). |
## References
| Source | Pertinence |
|--------|------------|
| [Socket.IO + Next.js guide](https://socket.io/how-to/use-with-nextjs) | Pattern custom server pour WebSocket avec Next.js. On utilise `ws` directement (plus leger). |
| [Next.js WebSocket discussion #58698](https://github.com/vercel/next.js/discussions/58698) | Confirme que les route handlers App Router ne supportent pas les WS — custom server necessaire. |
| [PowerSync React Native SDK](https://docs.powersync.com/client-sdks/reference/react-native-and-expo) | Alternative evaluee pour la sync. Trop lourd pour notre cas (single user). DIY LWW + tombstones suffit. |
| [Expo local-first guide](https://docs.expo.dev/guides/local-first/) | Patterns de sync offline-first avec expo-sqlite. Valide notre approche outbox + polling. |
| [OWASP WebSocket Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) | CSWSH, auth WS, validation Origin — base de notre strategie de ticket ephemere. |
| [OWASP API Security Top 10](https://owasp.org/www-project-api-security/) | BOLA (#1), Mass Assignment (#3) — guides les controles d'acces sur le batch sync. |
| [CSWSH Exploitation in 2025 — Include Security](https://blog.includesecurity.com/2025/04/cross-site-websocket-hijacking-exploitation-in-2025/) | Confirme que SameSite=Lax ne suffit pas si un sous-domaine est compromis. |
| [Logto Security docs](https://docs.logto.io/security) | Rotation des cles, logout centralise, stockage securise des tokens. |
| [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/) | API pour le stockage chiffre (Keychain iOS / Keystore Android) — adaptateur pour @logto/rn. |
## Estimation
7-10 sessions de travail, decoupees ainsi :
- Issues 1-2 (setup) : 1-2 sessions
- Issue 3 (auth) : 1 session
- Issue 4 (API) : 2 sessions
- Issue 5 (WebSocket) : 1 session
- Issue 6 (frontend) : 2-3 sessions
- Issue 7 (sync mobile) : 1-2 sessions

View file

@ -54,7 +54,7 @@ export async function createList(name: string, color?: string, icon?: string) {
color: color ?? null,
icon: icon ?? null,
position: maxPosition + 1,
isInbox: false,
is_inbox: false,
}).catch(() => {});
return id;

View file

@ -3,7 +3,7 @@ import { syncOutbox } from '../schema';
import { randomUUID } from '@/src/lib/uuid';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
type EntityType = 'task' | 'list' | 'tag' | 'taskTag';
type EntityType = 'task' | 'list' | 'tag' | 'task_tag';
type Action = 'create' | 'update' | 'delete';
/**

View file

@ -52,10 +52,7 @@ export async function setTagsForTask(taskId: string, tagIds: string[]) {
tagIds.map((tagId) => ({ taskId, tagId }))
);
}
// Send individual taskTag create operations (server expects entityId=taskId, data.tagId)
for (const tagId of tagIds) {
writeOutboxEntry('taskTag', taskId, 'create', { tagId }).catch(() => {});
}
writeOutboxEntry('task_tag', taskId, 'update', { task_id: taskId, tag_ids: tagIds }).catch(() => {});
}
export async function addTagToTask(taskId: string, tagId: string) {

View file

@ -176,12 +176,10 @@ export async function createTask(data: {
id,
title: data.title,
notes: data.notes ?? null,
completed: false,
completedAt: null,
priority: data.priority ?? 0,
dueDate: data.dueDate?.toISOString() ?? null,
listId: data.listId,
parentId: data.parentId ?? null,
due_date: data.dueDate?.toISOString() ?? null,
list_id: data.listId,
parent_id: data.parentId ?? null,
recurrence: sanitizedRecurrence,
}).catch(() => {});
@ -257,11 +255,10 @@ export async function updateTask(
title: task.title,
notes: task.notes,
completed: task.completed,
completedAt: task.completedAt ? new Date(task.completedAt).toISOString() : null,
priority: task.priority,
dueDate: task.dueDate ? new Date(task.dueDate).toISOString() : null,
listId: task.listId,
parentId: task.parentId,
due_date: task.dueDate ? new Date(task.dueDate).toISOString() : null,
list_id: task.listId,
parent_id: task.parentId,
recurrence: task.recurrence,
}).catch(() => {});
}

View file

@ -142,17 +142,7 @@
"never": "Never synced",
"connectedAs": "Connected: {{userId}}",
"syncEnabled": "Sync enabled",
"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"
"syncDescription": "Syncs your data across devices"
},
"widget": {
"title": "Simpl-Liste",

View file

@ -142,17 +142,7 @@
"never": "Jamais synchronisé",
"connectedAs": "Connecté : {{userId}}",
"syncEnabled": "Synchronisation activée",
"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"
"syncDescription": "Synchronise vos données entre appareils"
},
"widget": {
"title": "Simpl-Liste",

View file

@ -1,23 +0,0 @@
// 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;
}
}

View file

@ -1,11 +0,0 @@
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,19 +2,16 @@ 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';
import { syncWidgetData } from '@/src/services/widgetSync';
const SYNC_API_BASE = 'https://liste.lacompagniemaximus.com';
const INBOX_ID = '00000000-0000-0000-0000-000000000001';
interface SyncOperation {
idempotencyKey: string;
entityType: 'list' | 'task' | 'tag' | 'taskTag';
entityId: string;
action: 'create' | 'update' | 'delete';
data?: Record<string, unknown>;
interface SyncPushEntry {
id: string;
entity_type: string;
entity_id: string;
action: string;
payload: string;
created_at: string;
}
interface SyncPullChange {
@ -30,45 +27,21 @@ interface SyncPullResponse {
sync_token: string;
}
async function getAuthHeaders(): Promise<Record<string, string>> {
const token = await getAccessToken();
if (!token) return {};
function getAuthHeaders(): Record<string, string> {
const { userId } = useSettingsStore.getState();
if (!userId) return {};
// Placeholder: in real implementation, JWT from Logto would be used
return {
'Authorization': `Bearer ${token}`,
'Authorization': `Bearer ${userId}`,
'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 = await getAuthHeaders();
const headers = getAuthHeaders();
if (!headers['Authorization']) return;
const unsynced = await db
@ -78,28 +51,43 @@ export async function pushChanges(): Promise<void> {
if (unsynced.length === 0) return;
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,
};
});
// 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 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));
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
}
// Refresh widget after a successful push to reflect the synced state
syncWidgetData().catch(() => {});
}
}
@ -107,7 +95,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 = await getAuthHeaders();
const headers = getAuthHeaders();
if (!headers['Authorization']) return;
try {
@ -121,11 +109,9 @@ export async function pullChanges(since: string): Promise<void> {
const data: SyncPullResponse = await res.json();
let appliedChanges = 0;
for (const change of data.changes) {
try {
await applyChange(change);
appliedChanges++;
} catch (err) {
console.warn(`[sync] failed to apply change for ${change.entity_type}/${change.entity_id}:`, err);
}
@ -135,11 +121,6 @@ export async function pullChanges(since: string): Promise<void> {
if (data.sync_token) {
useSettingsStore.getState().setLastSyncAt(data.sync_token);
}
// Refresh widget once after applying all remote changes
if (appliedChanges > 0) {
syncWidgetData().catch(() => {});
}
} catch (err) {
console.warn('[sync] pull error:', err);
}
@ -280,149 +261,6 @@ 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

@ -8,7 +8,6 @@ import { startOfDay, endOfDay, addWeeks } from 'date-fns';
import { TaskListWidget } from '../widgets/TaskListWidget';
export const WIDGET_STATE_KEY = 'widget:state';
export const WIDGET_NAMES = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'] as const;
// Legacy keys — used for migration only
const LEGACY_DATA_KEY = 'widget:tasks';
@ -215,7 +214,7 @@ export async function syncWidgetData(): Promise<void> {
await setWidgetState(state);
// Request widget update for all 3 sizes
const widgetNames = WIDGET_NAMES;
const widgetNames = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'];
for (const widgetName of widgetNames) {
try {
await requestWidgetUpdate({

View file

@ -1,24 +1,11 @@
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import { requestWidgetUpdate } from 'react-native-android-widget';
import { TaskListWidget } from './TaskListWidget';
import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetSync';
import { getWidgetState, setWidgetState, type WidgetTask } from '../services/widgetSync';
import { isValidUUID } from '../lib/validation';
const EXPAND_DEBOUNCE_MS = 600;
const EXPAND_DEBOUNCE_MS = 2000;
const lastExpandTimes = new Map<string, number>();
// Dev-only timing helper. Output goes to logcat:
// adb logcat -s ReactNativeJS | grep '\[widget\]'
async function timed<T>(label: string, fn: () => Promise<T> | T): Promise<T> {
if (!__DEV__) return await fn();
const start = Date.now();
try {
return await fn();
} finally {
console.log(`[widget] ${label}: ${Date.now() - start}ms`);
}
}
function renderWithState(
renderWidget: WidgetTaskHandlerProps['renderWidget'],
widgetInfo: WidgetTaskHandlerProps['widgetInfo'],
@ -37,49 +24,17 @@ function renderWithState(
);
}
async function forceWidgetRefresh(
tasks: WidgetTask[],
isDark: boolean,
expandedTaskIds: string[],
): Promise<void> {
for (const widgetName of WIDGET_NAMES) {
try {
await requestWidgetUpdate({
widgetName,
renderWidget: (props) =>
TaskListWidget({ ...props, widgetName, tasks, isDark, expandedTaskIds }),
widgetNotFound: () => {},
});
} catch {
// Widget not placed on home screen
}
}
}
// Best-effort persist: failure leaves AsyncStorage stale, but the next
// handler call's getWidgetState() returns the prior state and re-renders
// from it, so the UI self-heals on the next interaction.
async function persistState(state: Awaited<ReturnType<typeof getWidgetState>>): Promise<void> {
try {
await setWidgetState(state);
} catch {
if (__DEV__) console.log('[widget] setWidgetState failed (state will resync on next sync push)');
}
}
export async function widgetTaskHandler(
props: WidgetTaskHandlerProps
): Promise<void> {
const { widgetAction, widgetInfo, renderWidget } = props;
const handlerStart = __DEV__ ? Date.now() : 0;
switch (widgetAction) {
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
const state = await timed(`${widgetAction} getState`, getWidgetState);
const state = await getWidgetState();
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
if (__DEV__) console.log(`[widget] ${widgetAction} total: ${Date.now() - handlerStart}ms`);
break;
}
@ -91,36 +46,31 @@ export async function widgetTaskHandler(
const taskId = props.clickActionData?.taskId;
if (!isValidUUID(taskId)) break;
const state = await timed('TOGGLE_COMPLETE getState', getWidgetState);
const state = await getWidgetState();
state.tasks = state.tasks.filter((t) => t.id !== taskId);
await setWidgetState(state);
// Render first so the user sees the row disappear immediately,
// then persist + run the DB write.
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
await timed('TOGGLE_COMPLETE setState', () => persistState(state));
try {
const { toggleComplete } = await import('../db/repository/tasks');
await timed('TOGGLE_COMPLETE db', () => toggleComplete(taskId));
await toggleComplete(taskId);
} catch {
// DB might not be available in headless mode
}
if (__DEV__) console.log(`[widget] TOGGLE_COMPLETE total: ${Date.now() - handlerStart}ms`);
}
if (props.clickAction === 'TOGGLE_EXPAND') {
const taskId = props.clickActionData?.taskId as string | undefined;
if (!taskId) break;
// Anti-double-tap. Short enough to not feel laggy when the user
// genuinely wants to expand-then-collapse.
// Debounce: ignore rapid double-taps on the same task
const now = Date.now();
const lastTime = lastExpandTimes.get(taskId) ?? 0;
if (now - lastTime < EXPAND_DEBOUNCE_MS) break;
lastExpandTimes.set(taskId, now);
const state = await timed('TOGGLE_EXPAND getState', getWidgetState);
const state = await getWidgetState();
const expandedSet = new Set(state.expandedTaskIds);
if (expandedSet.has(taskId)) {
@ -129,11 +79,9 @@ export async function widgetTaskHandler(
expandedSet.add(taskId);
}
state.expandedTaskIds = [...expandedSet];
await setWidgetState(state);
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
await timed('TOGGLE_EXPAND setState', () => persistState(state));
if (__DEV__) console.log(`[widget] TOGGLE_EXPAND total: ${Date.now() - handlerStart}ms`);
}
if (props.clickAction === 'TOGGLE_SUBTASK') {
@ -141,8 +89,9 @@ export async function widgetTaskHandler(
const parentId = props.clickActionData?.parentId as string | undefined;
if (!isValidUUID(subtaskId) || !parentId) break;
const state = await timed('TOGGLE_SUBTASK getState', getWidgetState);
const state = await getWidgetState();
// Update subtask state in cached data
const parent = state.tasks.find((t) => t.id === parentId);
if (parent) {
const sub = parent.subtasks?.find((s) => s.id === subtaskId);
@ -151,24 +100,16 @@ export async function widgetTaskHandler(
parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length;
}
}
await setWidgetState(state);
// forceWidgetRefresh fans out to all 3 widget sizes (state changed
// affects every widget on the home screen). Run before persist for
// immediate visual feedback; the data passed in is the in-memory
// mutated state, not re-read from AsyncStorage.
await timed('TOGGLE_SUBTASK render', () =>
forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds)
);
await timed('TOGGLE_SUBTASK setState', () => persistState(state));
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');
await timed('TOGGLE_SUBTASK db', () => toggleComplete(subtaskId));
await toggleComplete(subtaskId);
} catch {
// DB might not be available in headless mode
}
if (__DEV__) console.log(`[widget] TOGGLE_SUBTASK total: ${Date.now() - handlerStart}ms`);
}
break;
}

View file

@ -1,85 +0,0 @@
'use strict';
// Non-regression smoke tests for npm overrides + deps integrity.
// Runs on plain node (no jest, no Expo runtime). Catches the obvious
// breakage paths after `npm install` rewrites lock for an `overrides` bump.
//
// node tests/smoke.test.cjs
//
// Exit 0 if all checks pass, 1 if any fails.
const assert = require('node:assert').strict;
let failed = 0;
function check(name, fn) {
try {
fn();
console.log(`OK ${name}`);
} catch (e) {
failed++;
console.error(`FAIL ${name}: ${e.message}`);
}
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
check('package.json is valid JSON with name + deps', () => {
const pkg = require('../package.json');
assert.ok(pkg.name, 'package.json missing name');
assert.ok(pkg.dependencies, 'package.json missing dependencies');
});
check('uuid v4 generates well-formed UUID', () => {
const { v4 } = require('uuid');
for (let i = 0; i < 5; i++) {
assert.match(v4(), UUID_RE);
}
});
// The uuid <14.0.0 advisory (GHSA-w5hq-g745-h8pq) is specifically about
// missing buffer bounds checks in v3/v5/v6. After bumping to v14, these
// must still produce valid UUIDs from a name + namespace.
check('uuid v3 with namespace produces valid UUID', () => {
const { v3 } = require('uuid');
const id = v3('test-name', NAMESPACE);
assert.match(id, UUID_RE);
});
check('uuid v5 with namespace produces valid UUID', () => {
const { v5 } = require('uuid');
const id = v5('test-name', NAMESPACE);
assert.match(id, UUID_RE);
});
// Buffer-arg path is the actual vuln site — must not throw and must
// fill the buffer with the UUID bytes.
check('uuid v3 with buffer arg fills buffer (vuln site)', () => {
const { v3 } = require('uuid');
const buf = Buffer.alloc(16);
v3('test-name', NAMESPACE, buf);
// After the call, buf must have at least one non-zero byte.
assert.ok(
buf.some((b) => b !== 0),
'buffer was not filled by uuid v3'
);
});
check('uuid v5 with buffer arg fills buffer (vuln site)', () => {
const { v5 } = require('uuid');
const buf = Buffer.alloc(16);
v5('test-name', NAMESPACE, buf);
assert.ok(
buf.some((b) => b !== 0),
'buffer was not filled by uuid v5'
);
});
if (failed === 0) {
console.log('\nsmoke OK');
process.exit(0);
} else {
console.error(`\nsmoke FAIL (${failed} failure${failed > 1 ? 's' : ''})`);
process.exit(1);
}

View file

@ -14,8 +14,5 @@
".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
# Install production dependencies
# Dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
RUN npm ci --only=production
# Build
FROM base AS builder
@ -13,9 +13,6 @@ 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 --external:pg --external:pg-native --external:drizzle-orm
# Production
FROM base AS runner
@ -25,17 +22,15 @@ 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 ./.next
COPY --from=builder --chown=nextjs:nodejs /app/dist-server/server.js ./server.js
COPY --from=builder --chown=nextjs:nodejs /app/src/db/migrations ./src/db/migrations
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
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# Use custom server instead of default next start
CMD ["node", "server.ts"]

108
web/package-lock.json generated
View file

@ -12,14 +12,11 @@
"@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"
},
@ -241,15 +238,6 @@
"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",
@ -5155,55 +5143,6 @@
"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",
@ -6908,33 +6847,6 @@
"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",
@ -8325,7 +8237,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -8460,24 +8372,6 @@
"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,14 +13,11 @@
"@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

@ -1,38 +1,15 @@
import { createServer } from 'http';
import next from 'next';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { setupWebSocket } from './src/lib/ws';
const dev = process.env.NODE_ENV !== 'production';
const hostname = process.env.HOSTNAME || '0.0.0.0';
const port = parseInt(process.env.PORT || '3000', 10);
async function runMigrations() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
try {
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('> Migrations applied');
} finally {
await pool.end();
}
}
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
(async () => {
try {
await runMigrations();
} catch (err) {
console.error('> Migration error:', err);
process.exit(1);
}
await app.prepare();
app.prepare().then(() => {
const server = createServer((req, res) => {
// Don't log query params on /ws route (ticket security)
handle(req, res);
@ -44,4 +21,4 @@ const handle = app.getRequestHandler();
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server on ws://${hostname}:${port}/ws`);
});
})();
});

View file

@ -15,9 +15,7 @@ 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, redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { getAuthenticatedUser } from "@/lib/auth";
import { db } from "@/db/client";
import { slLists, slTasks } from "@/db/schema";
@ -17,8 +17,7 @@ export default async function ListPage({
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const user = await getAuthenticatedUser();
if (!user) redirect("/auth");
const userId = user.userId;
if (!user) notFound();
const { id: listId } = await params;
const search = await searchParams;
@ -30,7 +29,7 @@ export default async function ListPage({
.where(
and(
eq(slLists.id, listId),
eq(slLists.userId, userId),
eq(slLists.userId, user.userId),
isNull(slLists.deletedAt)
)
);
@ -40,7 +39,7 @@ export default async function ListPage({
// Build conditions
const conditions: SQL[] = [
eq(slTasks.listId, listId),
eq(slTasks.userId, userId),
eq(slTasks.userId, user.userId),
isNull(slTasks.deletedAt),
isNull(slTasks.parentId),
];
@ -82,12 +81,13 @@ export default async function ListPage({
.from(slTasks)
.where(
and(
eq(slTasks.userId, userId),
eq(slTasks.userId, user.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,24 +3,34 @@ 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, userId), isNull(slLists.deletedAt)))
.where(and(eq(slLists.userId, user.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}`);
return <WelcomeMessage />;
// 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>
);
}

View file

@ -1,40 +0,0 @@
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,7 +3,6 @@ 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(
@ -12,8 +11,6 @@ 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));
@ -38,8 +35,6 @@ 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,7 +3,6 @@ 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,
@ -11,8 +10,6 @@ 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,14 +3,11 @@ 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,14 +3,11 @@ 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()
@ -24,8 +21,6 @@ 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,15 +1,17 @@
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 callbackUrl = new URL(
`/api/logto/callback?${request.nextUrl.searchParams.toString()}`,
logtoConfig.baseUrl
);
await handleSignIn(logtoConfig, callbackUrl);
redirect('/');
const searchParams = request.nextUrl.searchParams;
try {
await handleSignIn(logtoConfig, searchParams);
redirect('/');
} catch {
redirect('/?error=auth');
}
}

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() {
// signIn calls redirect() internally — must not be in try/catch
export async function GET(request: NextRequest) {
await signIn(logtoConfig, `${logtoConfig.baseUrl}/api/logto/callback`);
}

View file

@ -1,16 +1,8 @@
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() {
// 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);
await signOut(logtoConfig, `${logtoConfig.baseUrl}`);
}

View file

@ -1,9 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists, slTasks, slTags, slTaskTags } from '@/db/schema';
import { eq, and, gte, isNull } from 'drizzle-orm';
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)
@ -24,8 +23,6 @@ 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) {
@ -64,95 +61,18 @@ export async function GET(request: NextRequest) {
.where(inArray(slTaskTags.taskId, taskIds));
}
// Transform entities into the changes format expected by the mobile client
const changes: {
entity_type: string;
entity_id: string;
action: 'create' | 'update' | 'delete';
payload: Record<string, unknown>;
updated_at: string;
}[] = [];
for (const l of lists) {
changes.push({
entity_type: 'list',
entity_id: l.id,
action: l.deletedAt ? 'delete' : 'update',
payload: {
name: l.name,
color: l.color,
icon: l.icon,
position: l.position,
is_inbox: l.isInbox,
created_at: l.createdAt.toISOString(),
updated_at: l.updatedAt.toISOString(),
},
updated_at: l.updatedAt.toISOString(),
});
}
for (const t of tasks) {
changes.push({
entity_type: 'task',
entity_id: t.id,
action: t.deletedAt ? 'delete' : 'update',
payload: {
title: t.title,
notes: t.notes,
completed: t.completed,
completed_at: t.completedAt?.toISOString() ?? null,
priority: t.priority,
due_date: t.dueDate?.toISOString() ?? null,
list_id: t.listId,
parent_id: t.parentId,
position: t.position,
recurrence: t.recurrence,
created_at: t.createdAt.toISOString(),
updated_at: t.updatedAt.toISOString(),
},
updated_at: t.updatedAt.toISOString(),
});
}
for (const tag of tags) {
changes.push({
entity_type: 'tag',
entity_id: tag.id,
action: tag.deletedAt ? 'delete' : 'update',
payload: {
name: tag.name,
color: tag.color,
created_at: tag.createdAt.toISOString(),
updated_at: tag.createdAt.toISOString(),
},
updated_at: tag.createdAt.toISOString(),
});
}
for (const tt of taskTags) {
changes.push({
entity_type: 'task_tag',
entity_id: `${tt.taskId}:${tt.tagId}`,
action: 'update',
payload: {
task_id: tt.taskId,
tag_id: tt.tagId,
},
updated_at: new Date().toISOString(),
});
}
return NextResponse.json({
changes,
sync_token: new Date().toISOString(),
lists,
tasks,
tags,
taskTags,
syncedAt: new Date().toISOString(),
});
}
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;
@ -197,43 +117,15 @@ async function processOperation(op: SyncOperation, userId: string) {
switch (entityType) {
case 'list': {
if (action === 'create') {
const d = (data as Record<string, unknown>) || {};
const incomingIsInbox = d.isInbox as boolean | undefined;
const listValues = {
await db.insert(slLists).values({
id: entityId,
userId,
name: d.name as string || 'Untitled',
color: d.color as string | undefined,
icon: d.icon as string | undefined,
position: d.position as number | undefined,
isInbox: incomingIsInbox,
};
// If the incoming list is an inbox, check for an existing inbox and merge
if (incomingIsInbox) {
await db.transaction(async (tx) => {
const [existingInbox] = await tx
.select()
.from(slLists)
.where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true), isNull(slLists.deletedAt)));
if (existingInbox && existingInbox.id !== entityId) {
// Reassign all tasks (including subtasks) from the old inbox to the new one
await tx.update(slTasks)
.set({ listId: entityId, updatedAt: now })
.where(and(eq(slTasks.listId, existingInbox.id), eq(slTasks.userId, userId)));
// Soft-delete the old inbox
await tx.update(slLists)
.set({ deletedAt: now, updatedAt: now })
.where(eq(slLists.id, existingInbox.id));
}
await tx.insert(slLists).values(listValues).onConflictDoNothing();
});
} else {
await db.insert(slLists).values(listValues).onConflictDoNothing();
}
name: (data as Record<string, unknown>)?.name as string || 'Untitled',
color: (data as Record<string, unknown>)?.color as string | undefined,
icon: (data as Record<string, unknown>)?.icon as string | undefined,
position: (data as Record<string, unknown>)?.position as number | undefined,
isInbox: (data as Record<string, unknown>)?.isInbox as boolean | undefined,
}).onConflictDoNothing();
} else if (action === 'update') {
await verifyOwnership(slLists, entityId, userId);
await db.update(slLists)
@ -265,14 +157,9 @@ async function processOperation(op: SyncOperation, userId: string) {
} else if (action === 'update') {
await verifyOwnership(slTasks, entityId, userId);
const raw = { ...(data as Record<string, unknown>), updatedAt: now } as Record<string, unknown>;
// Remove id from payload to avoid overwriting primary key
delete raw.id;
if (raw.dueDate !== undefined) {
raw.dueDate = raw.dueDate ? new Date(raw.dueDate as string) : null;
}
if (raw.completedAt !== undefined) {
raw.completedAt = raw.completedAt ? new Date(raw.completedAt as string) : null;
}
await db.update(slTasks)
.set(raw)
.where(and(eq(slTasks.id, entityId), eq(slTasks.userId, userId)));

View file

@ -3,7 +3,6 @@ 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(
@ -12,8 +11,6 @@ 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));
@ -38,8 +35,6 @@ 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,14 +3,11 @@ 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()
@ -24,8 +21,6 @@ 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,7 +3,6 @@ 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(
@ -12,8 +11,6 @@ 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));
@ -53,8 +50,6 @@ 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,7 +3,6 @@ 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,
@ -11,8 +10,6 @@ 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,7 +3,6 @@ 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,
@ -11,8 +10,6 @@ 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,7 +3,6 @@ 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(
@ -12,8 +11,6 @@ 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,14 +3,11 @@ 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,14 +3,11 @@ 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;
@ -25,20 +22,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
// If parentId, verify parent task belongs to user and is not itself a subtask
// If parentId, verify parent task belongs to user
if (body.data.parentId) {
const [parent] = await db
.select({ id: slTasks.id, parentId: slTasks.parentId })
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.id, body.data.parentId), eq(slTasks.userId, auth.userId)));
if (!parent) {
return NextResponse.json({ error: 'Parent task not found' }, { status: 404 });
}
if (parent.parentId) {
return NextResponse.json({ error: 'Cannot create sub-subtasks (max 2 levels)' }, { status: 400 });
}
}
const [task] = await db

View file

@ -1,7 +1,6 @@
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;
@ -19,8 +18,6 @@ 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,21 +1,19 @@
"use client";
import { useTranslation } from "react-i18next";
import Link from 'next/link';
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]">{t("app.name")}</h1>
<p className="text-[#6B6B6B]">{t("auth.subtitle")}</p>
<a
<h1 className="text-3xl font-bold text-[#1A1A1A]">Simpl-Liste</h1>
<p className="text-[#6B6B6B]">
Connectez-vous avec votre Compte Maximus
</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"
>
{t("auth.signIn")}
</a>
Se connecter
</Link>
</div>
</div>
);

View file

@ -1,7 +1,6 @@
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({
@ -33,9 +32,7 @@ export default function RootLayout({
<head>
<ThemeScript />
</head>
<body className="min-h-full flex flex-col">
<I18nProvider>{children}</I18nProvider>
</body>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

View file

@ -1,27 +0,0 @@
"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,28 +2,26 @@
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { Filter, ArrowUpDown } from "lucide-react";
import { useTranslation } from "react-i18next";
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" },
];
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";
@ -75,7 +73,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" ? t("sort.asc") : t("sort.desc")}
title={sortOrder === "asc" ? "Croissant" : "Décroissant"}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>

View file

@ -4,14 +4,12 @@ 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 (
@ -20,7 +18,7 @@ export function Header({ userName }: HeaderProps) {
<div className="w-10 md:hidden" />
<div className="hidden md:block text-sm font-medium text-bleu">
{t("app.name")}
Simpl-Liste
</div>
<div className="flex items-center gap-2">
@ -48,14 +46,14 @@ export function Header({ userName }: HeaderProps) {
<div className="px-3 py-2 text-xs text-foreground/50 truncate">
{userName}
</div>
<a
<Link
href="/api/logto/sign-out"
className="flex items-center gap-2 px-3 py-2 text-sm text-rouge hover:bg-rouge/10 transition-colors"
onClick={() => setMenuOpen(false)}
>
<LogOut size={14} />
{t("auth.signOut")}
</a>
Se déconnecter
</Link>
</div>
</>
)}

View file

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

View file

@ -5,15 +5,14 @@ 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 {
@ -22,7 +21,6 @@ interface SidebarProps {
}
export function Sidebar({ lists, tags }: SidebarProps) {
const { t } = useTranslation();
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
@ -47,13 +45,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">{t("app.name")}</h1>
<h1 className="text-lg font-bold text-bleu">Simpl-Liste</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">
{t("sidebar.lists")}
Listes
</p>
{lists.map((list) => {
const isActive = pathname === `/lists/${list.id}`;
@ -95,7 +93,7 @@ export function Sidebar({ lists, tags }: SidebarProps) {
setNewListName("");
}
}}
placeholder={t("sidebar.newListPlaceholder")}
placeholder="Nom de la liste..."
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>
@ -105,7 +103,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} />
{t("sidebar.newList")}
Nouvelle liste
</button>
)}
@ -120,7 +118,7 @@ export function Sidebar({ lists, tags }: SidebarProps) {
) : (
<ChevronRight size={12} />
)}
{t("sidebar.tags")}
Étiquettes
</button>
{tagsExpanded &&
tags.map((tag) => (
@ -137,13 +135,13 @@ export function Sidebar({ lists, tags }: SidebarProps) {
{/* Sign out */}
<div className="p-4 border-t border-border-light dark:border-border-dark">
<a
<Link
href="/api/logto/sign-out"
className="flex items-center gap-2 text-sm text-foreground/60 hover:text-rouge transition-colors"
>
<LogOut size={16} />
{t("auth.signOut")}
</a>
<List size={16} />
Se déconnecter
</Link>
</div>
</div>
);

View file

@ -3,7 +3,13 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, X } from "lucide-react";
import { useTranslation } from "react-i18next";
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" },
];
interface TaskFormProps {
listId: string;
@ -12,7 +18,6 @@ interface TaskFormProps {
}
export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {
const { t } = useTranslation();
const router = useRouter();
const [title, setTitle] = useState("");
const [notes, setNotes] = useState("");
@ -22,13 +27,6 @@ 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;
@ -68,7 +66,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} />
{t("task.add")}
Ajouter une tâche
</button>
);
}
@ -83,7 +81,7 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={parentId ? t("task.subtaskPlaceholder") : t("task.titlePlaceholder")}
placeholder={parentId ? "Nouvelle sous-tâche..." : "Titre de la tâche..."}
className="flex-1 bg-transparent text-sm focus:outline-none placeholder:text-foreground/40"
/>
{!parentId && (
@ -103,7 +101,7 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("task.notesPlaceholder")}
placeholder="Notes..."
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"
/>
@ -133,11 +131,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="">{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>
<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>
</select>
</div>
@ -151,14 +149,14 @@ export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {
}}
className="px-3 py-1.5 text-sm text-foreground/60 hover:text-foreground"
>
{t("task.cancel")}
Annuler
</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 ? "..." : t("task.addBtn")}
{submitting ? "..." : "Ajouter"}
</button>
</div>
</form>

View file

@ -9,9 +9,7 @@ import {
Calendar,
Repeat,
Check,
Search,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Task } from "@/lib/types";
import { TaskForm } from "./TaskForm";
@ -22,6 +20,13 @@ 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);
@ -31,6 +36,17 @@ 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[];
@ -38,10 +54,8 @@ interface TaskItemProps {
}
export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
const { t } = useTranslation();
const router = useRouter();
const [expanded, setExpanded] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(task.title);
const [notes, setNotes] = useState(task.notes || "");
@ -53,20 +67,6 @@ 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);
@ -125,21 +125,17 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
>
{/* Main row */}
<div className="flex items-center gap-2 px-3 py-2">
{/* Expand subtasks toggle — only shown when subtasks exist */}
{subtasks.length > 0 ? (
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 text-foreground/40 hover:text-foreground shrink-0"
>
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
) : (
<span className="w-[18px] shrink-0" />
)}
{/* Expand toggle */}
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 text-foreground/40 hover:text-foreground shrink-0"
>
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
{/* Checkbox */}
<button
@ -153,12 +149,12 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
{task.completed && <Check size={12} />}
</button>
{/* Title — click opens detail */}
{/* Title */}
<span
className={`flex-1 text-sm cursor-pointer ${
task.completed ? "line-through text-foreground/50" : ""
}`}
onClick={() => setDetailOpen(!detailOpen)}
onClick={() => setExpanded(!expanded)}
>
{task.title}
</span>
@ -180,20 +176,10 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
{subtasks.filter((s) => s.completed).length}/{subtasks.length}
</span>
)}
{/* Detail view toggle */}
<button
onClick={() => setDetailOpen(!detailOpen)}
className={`p-0.5 shrink-0 transition-colors ${
detailOpen ? "text-bleu" : "text-foreground/30 hover:text-foreground/60"
}`}
>
<Search size={14} />
</button>
</div>
{/* Detail view */}
{detailOpen && (
{/* Expanded view */}
{expanded && (
<div className="px-3 pb-3 pt-1 border-t border-border-light dark:border-border-dark">
{editing ? (
<div className="space-y-2">
@ -205,7 +191,7 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("task.notesPlaceholder")}
placeholder="Notes..."
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"
/>
@ -215,10 +201,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}>{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>
<option value={0}>Aucune priorité</option>
<option value={1}>Basse</option>
<option value={2}>Moyenne</option>
<option value={3}>Haute</option>
</select>
<input
type="date"
@ -231,11 +217,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="">{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>
<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>
</select>
</div>
<div className="flex gap-2 justify-end">
@ -243,14 +229,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"
>
{t("task.cancel")}
Annuler
</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 ? "..." : t("task.save")}
{saving ? "..." : "Enregistrer"}
</button>
</div>
</div>
@ -261,13 +247,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>{t("task.priorityLabel", { value: PRIORITY_LABELS[task.priority] })}</span>
<span>Priorité : {PRIORITY_LABELS[task.priority]}</span>
)}
{task.dueDate && (
<span>{t("task.dueDate", { value: formatDate(task.dueDate) })}</span>
<span>Échéance : {formatDate(task.dueDate)}</span>
)}
{task.recurrence && (
<span>{t("task.recurrenceLabel", { value: RECURRENCE_LABELS[task.recurrence] || task.recurrence })}</span>
<span>Récurrence : {recurrenceLabel(task.recurrence)}</span>
)}
</div>
<div className="flex gap-2 pt-1">
@ -275,22 +261,20 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
onClick={() => setEditing(true)}
className="text-xs text-bleu hover:underline"
>
{t("task.edit")}
Modifier
</button>
<button
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
className="text-xs text-bleu hover:underline"
>
+ Sous-tâche
</button>
{depth < 1 && (
<button
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
className="text-xs text-bleu hover:underline"
>
{t("task.addSubtask")}
</button>
)}
<button
onClick={deleteTask}
className="text-xs text-rouge hover:underline flex items-center gap-1"
>
<Trash2 size={12} />
{t("task.delete")}
Supprimer
</button>
</div>
</div>
@ -300,7 +284,7 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
</div>
{/* Subtask form */}
{showSubtaskForm && detailOpen && (
{showSubtaskForm && expanded && (
<div style={{ marginLeft: 24 }} className="mb-1.5">
<TaskForm
listId={task.listId}
@ -310,8 +294,8 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
</div>
)}
{/* Subtasks — toggled by chevron */}
{expanded && subtasks.map((sub) => (
{/* Subtasks */}
{subtasks.map((sub) => (
<TaskItem key={sub.id} task={sub} depth={depth + 1} />
))}
</div>

View file

@ -4,10 +4,8 @@ import type { Task } from "@/lib/types";
import { TaskItem } from "./TaskItem";
import { TaskForm } from "./TaskForm";
import { FilterBar } from "./FilterBar";
import { ClipboardList, RefreshCw } from "lucide-react";
import { Suspense, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { ClipboardList } from "lucide-react";
import { Suspense } from "react";
interface TaskListProps {
tasks: Task[];
@ -17,32 +15,11 @@ interface TaskListProps {
}
export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps) {
const { t } = useTranslation();
const router = useRouter();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
router.refresh();
// Brief visual feedback
setTimeout(() => setRefreshing(false), 500);
}, [router]);
return (
<div className="max-w-2xl mx-auto w-full">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">{listName}</h2>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-1.5 text-foreground/40 hover:text-foreground transition-colors disabled:opacity-50"
title={t("task.refresh")}
>
<RefreshCw size={18} className={refreshing ? "animate-spin" : ""} />
</button>
</div>
<h2 className="text-xl font-semibold mb-3">{listName}</h2>
<Suspense fallback={null}>
<FilterBar />
</Suspense>
@ -57,7 +34,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>{t("task.empty")}</p>
<p>Aucune tâche</p>
</div>
) : (
<div className="space-y-0">

View file

@ -2,12 +2,10 @@
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(() => {
@ -29,13 +27,12 @@ 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={t("theme.label", { value: themeLabel })}
title={`Thème : ${theme === "light" ? "clair" : theme === "dark" ? "sombre" : "système"}`}
>
<Icon size={20} />
</button>

View file

@ -1,16 +0,0 @@
"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

@ -1,54 +0,0 @@
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

@ -1,3 +0,0 @@
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

@ -1,45 +0,0 @@
-- Cleanup duplicate inboxes per user (#60)
-- For each user with more than one active inbox, keep the oldest one
-- (lowest created_at), reassign all tasks to it, and soft-delete the duplicates.
WITH ranked_inboxes AS (
SELECT
id,
user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
FROM sl_lists
WHERE is_inbox = true
AND deleted_at IS NULL
),
canonical AS (
SELECT user_id, id AS canonical_id
FROM ranked_inboxes
WHERE rn = 1
),
duplicates AS (
SELECT r.id AS duplicate_id, c.canonical_id, r.user_id
FROM ranked_inboxes r
JOIN canonical c ON c.user_id = r.user_id
WHERE r.rn > 1
)
-- Reassign tasks from duplicate inboxes to the canonical one
UPDATE sl_tasks
SET list_id = d.canonical_id, updated_at = NOW()
FROM duplicates d
WHERE sl_tasks.list_id = d.duplicate_id
AND sl_tasks.user_id = d.user_id;
--> statement-breakpoint
-- Soft-delete the duplicate inboxes
WITH ranked_inboxes AS (
SELECT
id,
user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
FROM sl_lists
WHERE is_inbox = true
AND deleted_at IS NULL
)
UPDATE sl_lists
SET deleted_at = NOW(), updated_at = NOW()
WHERE id IN (SELECT id FROM ranked_inboxes WHERE rn > 1);

View file

@ -1,410 +0,0 @@
{
"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

@ -1,27 +0,0 @@
{
"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
},
{
"idx": 2,
"version": "7",
"when": 1775649600000,
"tag": "0002_cleanup_duplicate_inboxes",
"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: text('user_id').notNull(),
userId: uuid('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: text('user_id').notNull(),
userId: uuid('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: text('user_id').notNull(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
color: text('color').notNull().default('#4A90A4'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View file

@ -19,8 +19,6 @@ async function seed() {
.where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true)));
if (existing.length === 0) {
// Let the DB generate a random UUID — the sync endpoint handles
// inbox deduplication when mobile pushes its fixed-ID inbox.
await db.insert(slLists).values({
userId,
name: 'Inbox',

View file

@ -1,74 +0,0 @@
{
"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",
"refresh": "Refresh",
"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..."
}
}

View file

@ -1,74 +0,0 @@
{
"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",
"refresh": "Rafraîchir",
"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..."
}
}

View file

@ -1,26 +0,0 @@
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,50 +1,13 @@
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

@ -1,22 +1,16 @@
import { cache } from 'react';
import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export const getAuthenticatedUser = cache(async () => {
try {
const context = await getLogtoContext(logtoConfig);
export async function getAuthenticatedUser() {
const context = await getLogtoContext(logtoConfig);
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);
if (!context.isAuthenticated || !context.claims?.sub) {
return null;
}
});
return {
userId: context.claims.sub,
email: context.claims.email,
name: context.claims.name,
};
}

View file

@ -1,56 +0,0 @@
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,12 +9,6 @@ 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(
@ -25,9 +19,9 @@ export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/auth', 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
if (hasSession && pathname === '/auth') {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}