Files
Momento/memento-mobile/app/(auth)/login.tsx
Antigravity 0fa8978395
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped
feat: mobile app complet + flashcards fixes + drag handle améliorations
Mobile app:
- Révision flashcards : liste decks, session flip-card SM-2, couleurs harmonisées web
- Génération flashcards depuis note (FlashcardSheet + route /api/mobile/flashcards/generate)
- Audio Whisper : hook useAudioRecorder reécrit, MicButton avec erreurs
- IA : AISheet (améliorer/clarifier/résumer), TitleSheet (titre automatique)
- Suppression note (soft delete + confirmation Alert)
- Note du jour : titre lisible + HTML (plus JSON TipTap brut)
- Parser TipTap→HTML côté mobile (tipTapToHtml)
- Icône 🎓 dans header note → génération flashcards
- Endpoint flashcardGenerate dans config.ts

Web fixes:
- Bug flashcards groupées par carnet → deck par note (migration + schema)
- Bug filtre 'cartes dues' ignoré (suppression fallback buildSessionQueue)
- Suppression UI création deck manuelle (inutile)
- Fix setViewType is not defined dans home-client.tsx

Drag handle menu:
- Fix : clearNodes() avant transformation (heading→liste/code/citation)
- Ajout : option 'Texte' (paragraphe) dans Transformer en
- Ajout : Monter / Descendre le bloc
- Ajout : Copier le contenu du bloc
- Fix : sous-menu hover stable (délai 200ms)
- Fix : Supprimer en rouge via classe --danger (plus :first-child)
- i18n : nouvelles clés dans 15 locales

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 18:49:40 +00:00

190 lines
7.3 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import {
View, Text, TextInput, TouchableOpacity,
KeyboardAvoidingView, Platform, ActivityIndicator,
Alert, StyleSheet,
} from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking'
import { useAuthStore } from '@/lib/store'
import { API_URL } from '@/lib/config'
import { C } from '@/lib/theme'
WebBrowser.maybeCompleteAuthSession()
export default function LoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPwd, setShowPwd] = useState(false)
const [loading, setLoading] = useState(false)
const [googleLoading, setGoogleLoading] = useState(false)
const { login, loginWithToken } = useAuthStore()
// Traitement du deep link OAuth — mémorisé pour éviter les re-créations
const handleOAuthCallback = useCallback(async (url: string) => {
const parsed = Linking.parse(url)
// memento://auth?token=... → scheme='memento', hostname='auth', path=''
if (parsed.scheme !== 'memento' || parsed.hostname !== 'auth') return
const p = parsed.queryParams as Record<string, string> | undefined
if (p?.error) {
Alert.alert('Connexion Google échouée', p.error === 'unauthorized' ? 'Non autorisé' : 'Erreur serveur')
setGoogleLoading(false)
return
}
if (p?.token && p?.id) {
await loginWithToken(p.token, {
id: p.id,
name: p.name || null,
email: p.email || '',
tier: p.tier || 'FREE',
})
}
setGoogleLoading(false)
}, [loginWithToken])
// Écoute le deep link (Android + app lancée via deep link)
useEffect(() => {
const sub = Linking.addEventListener('url', (event) => handleOAuthCallback(event.url))
Linking.getInitialURL().then((url) => { if (url) handleOAuthCallback(url) })
return () => sub.remove()
}, [handleOAuthCallback])
const handleLogin = async () => {
if (!email.trim() || !password.trim()) return
setLoading(true)
try {
await login(email.trim().toLowerCase(), password)
} catch (e: any) {
Alert.alert(
'Connexion échouée',
e.message?.includes('Identifiants invalides')
? 'Email ou mot de passe incorrect.\n\nSi vous vous êtes inscrit avec Google, utilisez le bouton Google ci-dessous.'
: e.message
)
} finally {
setLoading(false)
}
}
const handleGoogle = async () => {
setGoogleLoading(true)
try {
const url = `${API_URL}/api/mobile/auth/google-start`
const result = await WebBrowser.openAuthSessionAsync(url, 'memento://auth')
if (result.type === 'success') {
// iOS : le deep link est capturé dans result.url, pas via Linking.addEventListener
await handleOAuthCallback(result.url)
} else {
// Annulé par l'utilisateur
setGoogleLoading(false)
}
} catch (e: any) {
Alert.alert('Erreur', e.message)
setGoogleLoading(false)
}
}
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={s.container}>
<View style={s.inner}>
{/* Logo */}
<View style={s.logoBlock}>
<Text style={s.logo}>Momento</Text>
<Text style={s.tagline}>Votre espace de connaissance</Text>
</View>
{/* Bouton Google */}
<TouchableOpacity onPress={handleGoogle} disabled={googleLoading || loading} style={s.googleBtn} activeOpacity={0.8}>
{googleLoading
? <ActivityIndicator color={C.ink} size="small" />
: <>
<Text style={s.googleG}>G</Text>
<Text style={s.googleText}>Continuer avec Google</Text>
</>}
</TouchableOpacity>
{/* Séparateur */}
<View style={s.sep}>
<View style={s.sepLine} />
<Text style={s.sepText}>ou</Text>
<View style={s.sepLine} />
</View>
{/* Formulaire email/password */}
<View style={s.form}>
<Text style={s.label}>Email</Text>
<TextInput
value={email} onChangeText={setEmail}
placeholder="vous@exemple.com" autoCapitalize="none"
keyboardType="email-address" autoComplete="email"
style={s.input} placeholderTextColor={C.concrete}
/>
<Text style={[s.label, { marginTop: 16 }]}>Mot de passe</Text>
<View style={s.pwdRow}>
<TextInput
value={password} onChangeText={setPassword}
placeholder="••••••••"
secureTextEntry={!showPwd}
autoComplete="password"
style={[s.input, { flex: 1, marginRight: 8 }]}
placeholderTextColor={C.concrete}
onSubmitEditing={handleLogin}
/>
<TouchableOpacity onPress={() => setShowPwd(!showPwd)} style={s.eyeBtn}>
<Text style={s.eyeIcon}>{showPwd ? '🙈' : '👁️'}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handleLogin}
disabled={loading || !email || !password}
style={[s.btn, (loading || !email || !password) && s.btnDisabled]}
>
{loading
? <ActivityIndicator color={C.white} size="small" />
: <Text style={s.btnText}>Se connecter</Text>}
</TouchableOpacity>
</View>
<Text style={s.footer}>memento-note.com</Text>
</View>
</KeyboardAvoidingView>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: C.paper },
inner: { flex: 1, justifyContent: 'center', paddingHorizontal: 32 },
logoBlock: { marginBottom: 40, alignItems: 'center' },
logo: { fontSize: 36, fontStyle: 'italic', color: C.ink, fontWeight: '600' },
tagline: { fontSize: 14, color: C.concrete, marginTop: 4 },
googleBtn: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10,
backgroundColor: C.white, borderWidth: 1.5, borderColor: C.border,
borderRadius: 12, paddingVertical: 14,
shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 3,
},
googleG: { fontSize: 18, fontWeight: '700', color: '#4285F4' },
googleText: { fontSize: 15, fontWeight: '600', color: C.ink },
sep: { flexDirection: 'row', alignItems: 'center', marginVertical: 20, gap: 10 },
sepLine: { flex: 1, height: 1, backgroundColor: C.border },
sepText: { fontSize: 12, color: C.concrete, fontWeight: '500' },
form: {},
label: { fontSize: 11, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase', color: C.concrete, marginBottom: 6 },
input: { borderWidth: 1, borderColor: C.border, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13, fontSize: 15, color: C.ink, backgroundColor: C.white },
pwdRow: { flexDirection: 'row', alignItems: 'center' },
eyeBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 12 },
eyeIcon: { fontSize: 18 },
btn: { marginTop: 20, backgroundColor: C.ink, borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
btnDisabled: { opacity: 0.4 },
btnText: { color: C.white, fontWeight: '600', fontSize: 15 },
footer: { textAlign: 'center', fontSize: 12, color: C.concrete, marginTop: 32 },
})