Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
190 lines
7.3 KiB
TypeScript
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}>Memento</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 },
|
|
})
|