Compare commits
No commits in common. "master" and "v1.5.2" have entirely different histories.
24 changed files with 371 additions and 1441 deletions
|
|
@ -15,39 +15,10 @@ user-invocable: true
|
|||
|
||||
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
|
||||
```
|
||||
3. Si demande par l'utilisateur : bumper `version` dans `app.json` + `package.json`
|
||||
4. Commit : `chore: bump versionCode to <N>`
|
||||
5. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive`
|
||||
6. Quand le build est termine : creer une release Forgejo avec le lien APK
|
||||
|
||||
## Regles
|
||||
|
||||
|
|
@ -55,22 +26,3 @@ done
|
|||
- `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
|
||||
|
|
|
|||
50
SECURITY.md
50
SECURITY.md
|
|
@ -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.
|
||||
22
STATE.md
22
STATE.md
|
|
@ -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.
|
||||
4
app.json
4
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Simpl-Liste",
|
||||
"slug": "simpl-liste",
|
||||
"version": "1.6.4",
|
||||
"version": "1.5.2",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "simplliste",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"backgroundColor": "#FFF8F0"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"versionCode": 16
|
||||
"versionCode": 11
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
474
package-lock.json
generated
474
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "simpl-liste",
|
||||
"version": "1.6.3",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simpl-liste",
|
||||
"version": "1.6.3",
|
||||
"version": "1.5.1",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo/ngrok": "^4.1.3",
|
||||
|
|
@ -17,25 +17,25 @@
|
|||
"@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",
|
||||
"expo": "~54.0.33",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"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",
|
||||
|
|
@ -2202,9 +2202,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@expo/fingerprint": {
|
||||
"version": "0.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz",
|
||||
"integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==",
|
||||
"version": "0.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz",
|
||||
"integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
|
|
@ -2214,7 +2214,7 @@
|
|||
"getenv": "^2.0.0",
|
||||
"glob": "^13.0.0",
|
||||
"ignore": "^5.3.1",
|
||||
"minimatch": "^10.2.2",
|
||||
"minimatch": "^9.0.0",
|
||||
"p-limit": "^3.1.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.6.0"
|
||||
|
|
@ -2224,9 +2224,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@expo/fingerprint/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
|
@ -2266,15 +2266,24 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@expo/json-file": {
|
||||
"version": "10.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.14.tgz",
|
||||
"integrity": "sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==",
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz",
|
||||
"integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
"@babel/code-frame": "~7.10.4",
|
||||
"json5": "^2.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/json-file/node_modules/@babel/code-frame": {
|
||||
"version": "7.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
|
||||
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.10.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro": {
|
||||
"version": "54.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz",
|
||||
|
|
@ -2298,9 +2307,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@expo/metro-config": {
|
||||
"version": "54.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.15.tgz",
|
||||
"integrity": "sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw==",
|
||||
"version": "54.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz",
|
||||
"integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
|
|
@ -2321,7 +2330,7 @@
|
|||
"hermes-parser": "^0.29.1",
|
||||
"jsc-safe-url": "^0.2.4",
|
||||
"lightningcss": "^1.30.1",
|
||||
"picomatch": "^4.0.3",
|
||||
"minimatch": "^9.0.0",
|
||||
"postcss": "~8.4.32",
|
||||
"resolve-from": "^5.0.0"
|
||||
},
|
||||
|
|
@ -2334,18 +2343,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-config/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-runtime": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz",
|
||||
|
|
@ -2537,6 +2534,16 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@expo/ngrok/node_modules/uuid": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
|
||||
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
|
||||
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/ngrok/node_modules/yaml": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
|
|
@ -2547,24 +2554,25 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@expo/osascript": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.3.tgz",
|
||||
"integrity": "sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==",
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.3.8.tgz",
|
||||
"integrity": "sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"exec-async": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/package-manager": {
|
||||
"version": "1.10.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.5.tgz",
|
||||
"integrity": "sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==",
|
||||
"version": "1.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.9.10.tgz",
|
||||
"integrity": "sha512-axJm+NOj3jVxep49va/+L3KkF3YW/dkV+RwzqUJedZrv4LeTqOG4rhrCaCPXHTvLqCTDKu6j0Xyd28N7mnxsGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/json-file": "^10.0.14",
|
||||
"@expo/json-file": "^10.0.8",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"chalk": "^4.0.0",
|
||||
"npm-package-arg": "^11.0.0",
|
||||
|
|
@ -2664,9 +2672,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@expo/xcpretty": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz",
|
||||
"integrity": "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.0.tgz",
|
||||
"integrity": "sha512-o2qDlTqJ606h4xR36H2zWTywmZ3v3842K6TU8Ik2n1mfW0S580VHlt3eItVYdLYz+klaPp7CXqanja8eASZjRw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
|
|
@ -3808,9 +3816,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
@ -4348,24 +4356,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
|
|
@ -5726,30 +5722,36 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/exec-async": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
|
||||
"integrity": "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expo": {
|
||||
"version": "54.0.34",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.34.tgz",
|
||||
"integrity": "sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==",
|
||||
"version": "54.0.33",
|
||||
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
|
||||
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@expo/cli": "54.0.24",
|
||||
"@expo/cli": "54.0.23",
|
||||
"@expo/config": "~12.0.13",
|
||||
"@expo/config-plugins": "~54.0.4",
|
||||
"@expo/devtools": "0.1.8",
|
||||
"@expo/fingerprint": "0.15.5",
|
||||
"@expo/fingerprint": "0.15.4",
|
||||
"@expo/metro": "~54.2.0",
|
||||
"@expo/metro-config": "54.0.15",
|
||||
"@expo/metro-config": "54.0.14",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@ungap/structured-clone": "^1.3.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"expo-asset": "~12.0.13",
|
||||
"expo-asset": "~12.0.12",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-file-system": "~19.0.22",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-modules-autolinking": "3.0.25",
|
||||
"expo-modules-core": "3.0.30",
|
||||
"expo-modules-autolinking": "3.0.24",
|
||||
"expo-modules-core": "3.0.29",
|
||||
"pretty-format": "^29.7.0",
|
||||
"react-refresh": "^0.14.2",
|
||||
"whatwg-url-without-unicode": "8.0.0-3"
|
||||
|
|
@ -5788,13 +5790,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-asset": {
|
||||
"version": "12.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz",
|
||||
"integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==",
|
||||
"version": "12.0.12",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
|
||||
"integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.8.8",
|
||||
"expo-constants": "~18.0.13"
|
||||
"expo-constants": "~18.0.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
|
|
@ -5803,16 +5805,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-auth-session": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz",
|
||||
"integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==",
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz",
|
||||
"integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-web-browser": "~15.0.11",
|
||||
"expo-constants": "~18.0.11",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-linking": "~8.0.10",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -5845,9 +5847,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-crypto": {
|
||||
"version": "15.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
|
||||
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
|
||||
"integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0"
|
||||
|
|
@ -5857,9 +5859,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.22",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz",
|
||||
"integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==",
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
"integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
|
|
@ -5909,12 +5911,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-linking": {
|
||||
"version": "8.0.12",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz",
|
||||
"integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==",
|
||||
"version": "8.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz",
|
||||
"integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-constants": "~18.0.12",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -5936,9 +5938,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "3.0.25",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz",
|
||||
"integrity": "sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==",
|
||||
"version": "3.0.24",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
|
||||
"integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
|
|
@ -5952,9 +5954,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-modules-core": {
|
||||
"version": "3.0.30",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz",
|
||||
"integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==",
|
||||
"version": "3.0.29",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz",
|
||||
"integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
|
|
@ -5965,9 +5967,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-notifications": {
|
||||
"version": "0.32.17",
|
||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz",
|
||||
"integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==",
|
||||
"version": "0.32.16",
|
||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz",
|
||||
"integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.8.8",
|
||||
|
|
@ -6247,9 +6249,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-server": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz",
|
||||
"integrity": "sha512-vb5TBtskvEdzYuW79lATXutOEBfW5m6U4EFpNjCVZTnI7S//SAsLQkYEpn+EDfn84m6VQfzSGkIVR6YPaScKFA==",
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||
"integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.16.0"
|
||||
|
|
@ -6304,9 +6306,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "15.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
|
||||
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
|
||||
"version": "15.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
|
||||
"integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
|
|
@ -6314,9 +6316,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@expo/cli": {
|
||||
"version": "54.0.24",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz",
|
||||
"integrity": "sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==",
|
||||
"version": "54.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
|
||||
"integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@0no-co/graphql.web": "^1.0.8",
|
||||
|
|
@ -6328,7 +6330,7 @@
|
|||
"@expo/image-utils": "^0.8.8",
|
||||
"@expo/json-file": "^10.0.8",
|
||||
"@expo/metro": "~54.2.0",
|
||||
"@expo/metro-config": "~54.0.15",
|
||||
"@expo/metro-config": "~54.0.14",
|
||||
"@expo/osascript": "^2.3.8",
|
||||
"@expo/package-manager": "^1.9.10",
|
||||
"@expo/plist": "^0.4.8",
|
||||
|
|
@ -6351,16 +6353,16 @@
|
|||
"connect": "^3.7.0",
|
||||
"debug": "^4.3.4",
|
||||
"env-editor": "^0.4.1",
|
||||
"expo-server": "^1.0.6",
|
||||
"expo-server": "^1.0.5",
|
||||
"freeport-async": "^2.0.0",
|
||||
"getenv": "^2.0.0",
|
||||
"glob": "^13.0.0",
|
||||
"lan-network": "^0.2.1",
|
||||
"lan-network": "^0.1.6",
|
||||
"minimatch": "^9.0.0",
|
||||
"node-forge": "^1.3.3",
|
||||
"npm-package-arg": "^11.0.0",
|
||||
"ora": "^3.4.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"picomatch": "^3.0.1",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"pretty-format": "^29.7.0",
|
||||
"progress": "^2.0.3",
|
||||
|
|
@ -6400,15 +6402,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
|
|
@ -6424,37 +6417,22 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.2.tgz",
|
||||
"integrity": "sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
|
@ -6464,9 +6442,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
|
@ -6867,6 +6845,42 @@
|
|||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/balanced-match": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz",
|
||||
"integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/global-dirs": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz",
|
||||
|
|
@ -7743,9 +7757,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lan-network": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz",
|
||||
"integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==",
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz",
|
||||
"integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lan-network": "dist/lan-network-cli.js"
|
||||
|
|
@ -7786,9 +7800,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
"integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
|
|
@ -7801,23 +7815,23 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-android-arm64": "1.31.1",
|
||||
"lightningcss-darwin-arm64": "1.31.1",
|
||||
"lightningcss-darwin-x64": "1.31.1",
|
||||
"lightningcss-freebsd-x64": "1.31.1",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.31.1",
|
||||
"lightningcss-linux-arm64-gnu": "1.31.1",
|
||||
"lightningcss-linux-arm64-musl": "1.31.1",
|
||||
"lightningcss-linux-x64-gnu": "1.31.1",
|
||||
"lightningcss-linux-x64-musl": "1.31.1",
|
||||
"lightningcss-win32-arm64-msvc": "1.31.1",
|
||||
"lightningcss-win32-x64-msvc": "1.31.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
|
||||
"integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -7835,9 +7849,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
|
||||
"integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -7855,9 +7869,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
|
||||
"integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -7875,9 +7889,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
|
||||
"integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -7895,9 +7909,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
|
||||
"integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -7915,9 +7929,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
|
||||
"integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -7935,9 +7949,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
|
||||
"integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -7955,9 +7969,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
|
||||
"integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -7975,9 +7989,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
|
||||
"integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -7995,9 +8009,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
|
||||
"integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -8015,9 +8029,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
|
||||
"integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -8639,15 +8653,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
|
|
@ -8840,9 +8854,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/npm-package-arg/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
|
@ -9319,9 +9333,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -9338,7 +9352,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -11358,9 +11372,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
|
|
@ -11664,9 +11678,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
||||
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
|
@ -11859,16 +11873,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz",
|
||||
"integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/validate-npm-package-name": {
|
||||
|
|
@ -12200,9 +12210,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/wonka": {
|
||||
"version": "6.3.6",
|
||||
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz",
|
||||
"integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==",
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",
|
||||
"integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
|
|
|
|||
20
package.json
20
package.json
|
|
@ -1,10 +1,9 @@
|
|||
{
|
||||
"name": "simpl-liste",
|
||||
"main": "index.js",
|
||||
"version": "1.6.4",
|
||||
"version": "1.5.2",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"test": "node tests/smoke.test.cjs",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
|
|
@ -19,25 +18,25 @@
|
|||
"@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",
|
||||
"expo": "~54.0.33",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"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 +64,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -4,7 +4,6 @@ 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';
|
||||
|
|
@ -98,8 +97,6 @@ export async function pushChanges(): Promise<void> {
|
|||
.set({ syncedAt: now })
|
||||
.where(eq(syncOutbox.id, entry.id));
|
||||
}
|
||||
// Refresh widget after a successful push to reflect the synced state
|
||||
syncWidgetData().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,11 +118,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 +130,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,21 +4,9 @@ import { TaskListWidget } from './TaskListWidget';
|
|||
import { getWidgetState, setWidgetState, WIDGET_NAMES, 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'],
|
||||
|
|
@ -56,30 +44,17 @@ async function forceWidgetRefresh(
|
|||
}
|
||||
}
|
||||
|
||||
// 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 +66,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 +99,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 +109,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 +120,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));
|
||||
await forceWidgetRefresh(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ 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
|
||||
--external:next --external:.next
|
||||
|
||||
# Production
|
||||
FROM base AS runner
|
||||
|
|
@ -31,7 +31,6 @@ 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
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
})();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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';
|
||||
|
|
@ -197,43 +197,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)
|
||||
|
|
|
|||
|
|
@ -25,20 +25,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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
Calendar,
|
||||
Repeat,
|
||||
Check,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Task } from "@/lib/types";
|
||||
|
|
@ -41,7 +40,6 @@ 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 || "");
|
||||
|
|
@ -125,21 +123,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 +147,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 +174,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">
|
||||
|
|
@ -277,14 +261,12 @@ export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
|
|||
>
|
||||
{t("task.edit")}
|
||||
</button>
|
||||
{depth < 1 && (
|
||||
<button
|
||||
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
|
||||
className="text-xs text-bleu hover:underline"
|
||||
>
|
||||
{t("task.addSubtask")}
|
||||
</button>
|
||||
)}
|
||||
<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"
|
||||
|
|
@ -300,7 +282,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 +292,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>
|
||||
|
|
|
|||
|
|
@ -4,9 +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 { ClipboardList } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TaskListProps {
|
||||
|
|
@ -18,31 +17,12 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -15,13 +15,6 @@
|
|||
"when": 1775567900000,
|
||||
"tag": "0001_change_user_id_to_text",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1775649600000,
|
||||
"tag": "0002_cleanup_duplicate_inboxes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
"subtaskPlaceholder": "New subtask...",
|
||||
"notesPlaceholder": "Notes...",
|
||||
"empty": "No tasks",
|
||||
"refresh": "Refresh",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
"subtaskPlaceholder": "Nouvelle sous-tâche...",
|
||||
"notesPlaceholder": "Notes...",
|
||||
"empty": "Aucune tâche",
|
||||
"refresh": "Rafraîchir",
|
||||
"edit": "Modifier",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
|
|
|
|||
Loading…
Reference in a new issue