mobile: login - bouton Google OAuth + show/hide password + message erreur Google
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m18s
CI / Deploy production (on server) (push) Has been skipped

- login.tsx: bouton 'Continuer avec Google' (expo-web-browser + deep link memento://auth)
- login.tsx: bouton oeil pour afficher/masquer mot de passe
- login.tsx: message d'erreur contextuel si compte Google (pas de mot de passe en DB)
- store.ts: loginWithToken() pour recevoir le token après OAuth Google
- google-start/route.ts: lance le flux NextAuth Google avec redirect callback
- google-callback/route.ts: reçoit la session, génère token mobile, redirige vers memento://auth

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Antigravity
2026-05-29 17:09:06 +00:00
parent 725bf2c445
commit d06ea93f11
4 changed files with 171 additions and 14 deletions

View File

@@ -1,17 +1,53 @@
import { useState } from 'react'
import { useState, useEffect } 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 login = useAuthStore((s) => s.login)
const [googleLoading, setGoogleLoading] = useState(false)
const { login, loginWithToken } = useAuthStore()
// Écoute le deep link memento://auth?token=...
useEffect(() => {
const handleUrl = async (event: { url: string }) => {
const parsed = Linking.parse(event.url)
if (parsed.scheme === 'memento' && parsed.path === 'auth') {
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)
}
}
const sub = Linking.addEventListener('url', handleUrl)
// Vérifier si l'app a été lancée via un deep link
Linking.getInitialURL().then((url) => { if (url) handleUrl({ url }) })
return () => sub.remove()
}, [])
const handleLogin = async () => {
if (!email.trim() || !password.trim()) return
@@ -19,20 +55,61 @@ export default function LoginScreen() {
try {
await login(email.trim().toLowerCase(), password)
} catch (e: any) {
Alert.alert('Connexion échouée', e.message)
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')
// Si l'utilisateur ferme sans terminer
if (result.type !== 'success') {
setGoogleLoading(false)
}
// Si succès, le deep link listener prend le relais
} 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
@@ -41,13 +118,23 @@ export default function LoginScreen() {
keyboardType="email-address" autoComplete="email"
style={s.input} placeholderTextColor={C.concrete}
/>
<Text style={[s.label, { marginTop: 16 }]}>Mot de passe</Text>
<TextInput
value={password} onChangeText={setPassword}
placeholder="••••••••" secureTextEntry
autoComplete="password" style={s.input}
placeholderTextColor={C.concrete} onSubmitEditing={handleLogin}
/>
<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}
@@ -68,14 +155,32 @@ export default function LoginScreen() {
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: C.paper },
inner: { flex: 1, justifyContent: 'center', paddingHorizontal: 32 },
logoBlock: { marginBottom: 48, alignItems: 'center' },
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: 12, fontSize: 15, color: C.ink, backgroundColor: C.white },
btn: { marginTop: 24, backgroundColor: C.ink, borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
btnDisabled: { opacity: 0.5 },
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 },
})

View File

@@ -29,7 +29,7 @@ export const useAuthStore = create<AuthState>((set) => ({
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const data = await res.json()
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Identifiants invalides')
}
const { token, user } = await res.json()
@@ -37,6 +37,11 @@ export const useAuthStore = create<AuthState>((set) => ({
set({ user })
},
loginWithToken: async (token, user) => {
await setToken(token)
set({ user })
},
logout: async () => {
await clearToken()
set({ user: null })

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { createMobileToken } from '@/lib/mobile-auth'
// Appelé après le flux Google OAuth — génère un token mobile et redirige vers l'app native
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.email) {
return NextResponse.redirect(`memento://auth?error=unauthorized`)
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
select: { id: true, name: true, email: true, subscription: { select: { tier: true } } },
})
if (!user) {
return NextResponse.redirect(`memento://auth?error=not_found`)
}
const token = createMobileToken(user.id)
const params = new URLSearchParams({
token,
id: user.id,
name: user.name ?? '',
email: user.email ?? '',
tier: user.subscription?.tier ?? 'FREE',
})
return NextResponse.redirect(`memento://auth?${params.toString()}`)
} catch (e) {
console.error('[mobile/auth/google-callback]', e)
return NextResponse.redirect(`memento://auth?error=server_error`)
}
}

View File

@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
// Lance le flux Google OAuth et redirige vers le callback mobile
export async function GET(req: NextRequest) {
const baseUrl = process.env.NEXTAUTH_URL || 'https://memento-note.com'
const callbackUrl = `${baseUrl}/api/mobile/auth/google-callback`
const signinUrl = `${baseUrl}/api/auth/signin/google?callbackUrl=${encodeURIComponent(callbackUrl)}`
return NextResponse.redirect(signinUrl)
}