mobile: login - bouton Google OAuth + show/hide password + message erreur Google
- 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:
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
38
memento-note/app/api/mobile/auth/google-callback/route.ts
Normal file
38
memento-note/app/api/mobile/auth/google-callback/route.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
9
memento-note/app/api/mobile/auth/google-start/route.ts
Normal file
9
memento-note/app/api/mobile/auth/google-start/route.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user