Add OTP-based email authentication flow
- Replace username/password login with email OTP flow - Add verify-otp screen with 6-digit code input - Add complete-profile screen for new users - Update AuthContext with refreshAuth() method - Add new API methods: requestOTP, verifyOTP, getMe, updateProfile - Backend: wellnuo.smartlaunchhub.com 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
19d24e7b00
commit
ddfe5c7bd6
200
app/(auth)/complete-profile.tsx
Normal file
200
app/(auth)/complete-profile.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { api } from '@/services/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||
|
||||
export default function CompleteProfileScreen() {
|
||||
const { email } = useLocalSearchParams<{ email: string }>();
|
||||
const { refreshAuth } = useAuth();
|
||||
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleComplete = useCallback(async () => {
|
||||
setError(null);
|
||||
|
||||
// Validate
|
||||
if (!firstName.trim()) {
|
||||
setError('Please enter your first name');
|
||||
return;
|
||||
}
|
||||
if (!lastName.trim()) {
|
||||
setError('Please enter your last name');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.updateProfile({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
phone: phone.trim() || undefined,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh auth state and go to main app
|
||||
await refreshAuth();
|
||||
router.replace('/(tabs)');
|
||||
} else {
|
||||
setError(response.error?.message || 'Failed to update profile');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [firstName, lastName, phone, refreshAuth]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="person-circle-outline" size={48} color={AppColors.primary} />
|
||||
</View>
|
||||
<Text style={styles.title}>Complete your profile</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Just a few more details to get you started
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View style={styles.form}>
|
||||
{error && (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="First Name"
|
||||
placeholder="Enter your first name"
|
||||
leftIcon="person-outline"
|
||||
value={firstName}
|
||||
onChangeText={(text) => {
|
||||
setFirstName(text);
|
||||
setError(null);
|
||||
}}
|
||||
autoCapitalize="words"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Last Name"
|
||||
placeholder="Enter your last name"
|
||||
leftIcon="person-outline"
|
||||
value={lastName}
|
||||
onChangeText={(text) => {
|
||||
setLastName(text);
|
||||
setError(null);
|
||||
}}
|
||||
autoCapitalize="words"
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Phone (optional)"
|
||||
placeholder="Enter your phone number"
|
||||
leftIcon="call-outline"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Get Started"
|
||||
onPress={handleComplete}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size="lg"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Email info */}
|
||||
<View style={styles.emailInfo}>
|
||||
<Ionicons name="mail-outline" size={16} color={AppColors.textMuted} />
|
||||
<Text style={styles.emailText}>{email}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingTop: Spacing.xxl + Spacing.xl,
|
||||
paddingBottom: Spacing.xl,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: BorderRadius.full,
|
||||
backgroundColor: `${AppColors.primary}15`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSizes['2xl'],
|
||||
fontWeight: '700',
|
||||
color: AppColors.textPrimary,
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
emailInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
emailText: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textMuted,
|
||||
},
|
||||
});
|
||||
@ -10,43 +10,59 @@ import {
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { api } from '@/services/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { login, isLoading, error, clearError } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
// Clear previous errors
|
||||
clearError();
|
||||
setValidationError(null);
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
// Validate
|
||||
if (!username.trim()) {
|
||||
setValidationError('Username is required');
|
||||
const handleContinue = useCallback(async () => {
|
||||
setError(null);
|
||||
|
||||
// Validate email
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
if (!trimmedEmail) {
|
||||
setError('Please enter your email address');
|
||||
return;
|
||||
}
|
||||
if (!password.trim()) {
|
||||
setValidationError('Password is required');
|
||||
if (!validateEmail(trimmedEmail)) {
|
||||
setError('Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await login({ username: username.trim(), password });
|
||||
setIsLoading(true);
|
||||
|
||||
if (success) {
|
||||
// Clear password from memory after successful login
|
||||
setPassword('');
|
||||
router.replace('/(tabs)');
|
||||
try {
|
||||
const response = await api.requestOTP(trimmedEmail);
|
||||
|
||||
if (response.ok && response.data) {
|
||||
// Navigate to OTP screen with email
|
||||
router.push({
|
||||
pathname: '/(auth)/verify-otp',
|
||||
params: {
|
||||
email: trimmedEmail,
|
||||
isNewUser: response.data.isNewUser ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setError(response.error?.message || 'Failed to send verification code');
|
||||
}
|
||||
}, [username, password, login, clearError]);
|
||||
|
||||
const displayError = validationError || error?.message;
|
||||
} catch (err) {
|
||||
setError('Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [email]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
@ -65,70 +81,54 @@ export default function LoginScreen() {
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Sign in to continue monitoring your loved ones</Text>
|
||||
<Text style={styles.title}>Welcome to WellNuo</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Enter your email to get started. We'll send you a verification code.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Form */}
|
||||
<View style={styles.form}>
|
||||
{displayError && (
|
||||
{error && (
|
||||
<ErrorMessage
|
||||
message={displayError}
|
||||
onDismiss={() => {
|
||||
clearError();
|
||||
setValidationError(null);
|
||||
}}
|
||||
message={error}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
leftIcon="person-outline"
|
||||
value={username}
|
||||
label="Email Address"
|
||||
placeholder="Enter your email"
|
||||
leftIcon="mail-outline"
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
setUsername(text);
|
||||
setValidationError(null);
|
||||
setEmail(text);
|
||||
setError(null);
|
||||
}}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="email"
|
||||
editable={!isLoading}
|
||||
onSubmitEditing={handleContinue}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
leftIcon="lock-closed-outline"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
setValidationError(null);
|
||||
}}
|
||||
editable={!isLoading}
|
||||
onSubmitEditing={handleLogin}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
|
||||
<TouchableOpacity style={styles.forgotPassword} onPress={() => router.push('/(auth)/forgot-password')}>
|
||||
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Button
|
||||
title="Sign In"
|
||||
onPress={handleLogin}
|
||||
title="Continue"
|
||||
onPress={handleContinue}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size="lg"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Don't have an account? </Text>
|
||||
<TouchableOpacity onPress={() => router.push('/(auth)/register')}>
|
||||
<Text style={styles.footerLink}>Create Account</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Info */}
|
||||
<View style={styles.infoContainer}>
|
||||
<Text style={styles.infoText}>
|
||||
We'll send a 6-digit code to your email for verification.
|
||||
No password needed!
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Version Info */}
|
||||
@ -168,34 +168,22 @@ const styles = StyleSheet.create({
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: Spacing.md,
|
||||
},
|
||||
form: {
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
forgotPassword: {
|
||||
alignSelf: 'flex-end',
|
||||
marginBottom: Spacing.lg,
|
||||
marginTop: -Spacing.sm,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.primary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
infoContainer: {
|
||||
backgroundColor: AppColors.surface,
|
||||
borderRadius: BorderRadius.lg,
|
||||
padding: Spacing.md,
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: FontSizes.base,
|
||||
infoText: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
footerLink: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.primary,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
version: {
|
||||
textAlign: 'center',
|
||||
|
||||
355
app/(auth)/verify-otp.tsx
Normal file
355
app/(auth)/verify-otp.tsx
Normal file
@ -0,0 +1,355 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { api } from '@/services/api';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||
|
||||
const CODE_LENGTH = 6;
|
||||
|
||||
export default function VerifyOTPScreen() {
|
||||
const { email, isNewUser } = useLocalSearchParams<{ email: string; isNewUser: string }>();
|
||||
const { refreshAuth } = useAuth();
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// Countdown timer for resend
|
||||
useEffect(() => {
|
||||
if (resendCountdown > 0) {
|
||||
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [resendCountdown]);
|
||||
|
||||
const handleCodeChange = (text: string) => {
|
||||
// Only allow digits
|
||||
const digits = text.replace(/\D/g, '').slice(0, CODE_LENGTH);
|
||||
setCode(digits);
|
||||
setError(null);
|
||||
|
||||
// Auto-submit when code is complete
|
||||
if (digits.length === CODE_LENGTH) {
|
||||
handleVerify(digits);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = useCallback(async (verifyCode?: string) => {
|
||||
const codeToVerify = verifyCode || code;
|
||||
|
||||
if (codeToVerify.length !== CODE_LENGTH) {
|
||||
setError('Please enter the 6-digit code');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.verifyOTP(email!, codeToVerify);
|
||||
|
||||
if (response.ok && response.data) {
|
||||
// Refresh auth state
|
||||
await refreshAuth();
|
||||
|
||||
// Check if new user needs to complete profile
|
||||
const user = response.data.user;
|
||||
if (!user.firstName || !user.lastName) {
|
||||
// New user - go to complete profile
|
||||
router.replace({
|
||||
pathname: '/(auth)/complete-profile',
|
||||
params: { email: email! },
|
||||
});
|
||||
} else {
|
||||
// Existing user - go to main app
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
} else {
|
||||
setError(response.error?.message || 'Invalid code. Please try again.');
|
||||
setCode('');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Something went wrong. Please try again.');
|
||||
setCode('');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [code, email, refreshAuth]);
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
if (resendCountdown > 0) return;
|
||||
|
||||
setIsResending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.requestOTP(email!);
|
||||
|
||||
if (response.ok) {
|
||||
setResendCountdown(60); // 60 seconds cooldown
|
||||
setCode('');
|
||||
} else {
|
||||
setError(response.error?.message || 'Failed to resend code');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to resend code. Please try again.');
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
}
|
||||
}, [email, resendCountdown]);
|
||||
|
||||
// Render code input boxes
|
||||
const renderCodeBoxes = () => {
|
||||
const boxes = [];
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
const isActive = i === code.length;
|
||||
const isFilled = i < code.length;
|
||||
|
||||
boxes.push(
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
styles.codeBox,
|
||||
isActive && styles.codeBoxActive,
|
||||
isFilled && styles.codeBoxFilled,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.codeDigit}>
|
||||
{code[i] || ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return boxes;
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Back Button */}
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="mail-open-outline" size={48} color={AppColors.primary} />
|
||||
</View>
|
||||
<Text style={styles.title}>Check your email</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
We sent a verification code to
|
||||
</Text>
|
||||
<Text style={styles.email}>{email}</Text>
|
||||
</View>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Code Input */}
|
||||
<TouchableOpacity
|
||||
style={styles.codeContainer}
|
||||
activeOpacity={1}
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
>
|
||||
{renderCodeBoxes()}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Hidden actual input */}
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.hiddenInput}
|
||||
value={code}
|
||||
onChangeText={handleCodeChange}
|
||||
keyboardType="number-pad"
|
||||
maxLength={CODE_LENGTH}
|
||||
autoComplete="one-time-code"
|
||||
textContentType="oneTimeCode"
|
||||
/>
|
||||
|
||||
{/* Verify Button */}
|
||||
<Button
|
||||
title="Verify"
|
||||
onPress={() => handleVerify()}
|
||||
loading={isLoading}
|
||||
disabled={code.length !== CODE_LENGTH}
|
||||
fullWidth
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Resend */}
|
||||
<View style={styles.resendContainer}>
|
||||
<Text style={styles.resendText}>Didn't receive the code? </Text>
|
||||
{resendCountdown > 0 ? (
|
||||
<Text style={styles.resendCountdown}>
|
||||
Resend in {resendCountdown}s
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handleResend} disabled={isResending}>
|
||||
<Text style={[styles.resendLink, isResending && styles.resendLinkDisabled]}>
|
||||
{isResending ? 'Sending...' : 'Resend'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Change email */}
|
||||
<TouchableOpacity style={styles.changeEmailButton} onPress={() => router.back()}>
|
||||
<Text style={styles.changeEmailText}>Use a different email</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: AppColors.background,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingTop: Spacing.xl,
|
||||
paddingBottom: Spacing.xl,
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: BorderRadius.full,
|
||||
backgroundColor: AppColors.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: BorderRadius.full,
|
||||
backgroundColor: `${AppColors.primary}15`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: FontSizes['2xl'],
|
||||
fontWeight: '700',
|
||||
color: AppColors.textPrimary,
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
email: {
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: '600',
|
||||
color: AppColors.textPrimary,
|
||||
marginTop: Spacing.xs,
|
||||
},
|
||||
codeContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: Spacing.sm,
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
codeBox: {
|
||||
width: 48,
|
||||
height: 56,
|
||||
borderRadius: BorderRadius.md,
|
||||
borderWidth: 2,
|
||||
borderColor: AppColors.border,
|
||||
backgroundColor: AppColors.surface,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
codeBoxActive: {
|
||||
borderColor: AppColors.primary,
|
||||
},
|
||||
codeBoxFilled: {
|
||||
borderColor: AppColors.primary,
|
||||
backgroundColor: `${AppColors.primary}10`,
|
||||
},
|
||||
codeDigit: {
|
||||
fontSize: FontSizes['2xl'],
|
||||
fontWeight: '700',
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
hiddenInput: {
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
resendContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: Spacing.xl,
|
||||
},
|
||||
resendText: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textSecondary,
|
||||
},
|
||||
resendLink: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
resendLinkDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
resendCountdown: {
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.textMuted,
|
||||
},
|
||||
changeEmailButton: {
|
||||
alignItems: 'center',
|
||||
marginTop: Spacing.lg,
|
||||
},
|
||||
changeEmailText: {
|
||||
fontSize: FontSizes.sm,
|
||||
color: AppColors.textSecondary,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
@ -1,6 +1,20 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { api, setOnUnauthorizedCallback } from '@/services/api';
|
||||
import type { User, LoginCredentials, ApiError } from '@/types';
|
||||
import type { ApiError } from '@/types';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
phone: string | null;
|
||||
role: string;
|
||||
// Legacy fields for backward compatibility
|
||||
user_id?: number;
|
||||
user_name?: string;
|
||||
max_role?: number;
|
||||
privileges?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
@ -10,9 +24,9 @@ interface AuthState {
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (credentials: LoginCredentials) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
refreshAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
@ -48,13 +62,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const isAuth = await api.isAuthenticated();
|
||||
if (isAuth) {
|
||||
const user = await api.getStoredUser();
|
||||
// Try to get user from new API
|
||||
const response = await api.getMe();
|
||||
if (response.ok && response.data) {
|
||||
const userData = response.data.user;
|
||||
setState({
|
||||
user,
|
||||
user: {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
firstName: userData.firstName,
|
||||
lastName: userData.lastName,
|
||||
phone: userData.phone,
|
||||
role: userData.role,
|
||||
// Legacy compatibility
|
||||
user_name: userData.email.split('@')[0],
|
||||
},
|
||||
isLoading: false,
|
||||
isAuthenticated: !!user,
|
||||
isAuthenticated: true,
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
// Token invalid or expired
|
||||
await api.logout();
|
||||
setState({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState({
|
||||
user: null,
|
||||
@ -73,46 +109,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
const login = useCallback(async (credentials: LoginCredentials): Promise<boolean> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await api.login(credentials.username, credentials.password);
|
||||
|
||||
if (response.ok && response.data) {
|
||||
const user: User = {
|
||||
user_id: response.data.user_id,
|
||||
user_name: credentials.username,
|
||||
max_role: response.data.max_role,
|
||||
privileges: response.data.privileges,
|
||||
};
|
||||
|
||||
setState({
|
||||
user,
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || { message: 'Login failed' },
|
||||
}));
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: { message: error instanceof Error ? error.message : 'Login failed' },
|
||||
}));
|
||||
|
||||
return false;
|
||||
}
|
||||
const refreshAuth = useCallback(async () => {
|
||||
await checkAuth();
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
@ -135,7 +133,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...state, login, logout, clearError }}>
|
||||
<AuthContext.Provider value={{ ...state, logout, clearError, refreshAuth }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
236
services/api.ts
236
services/api.ts
@ -10,6 +10,7 @@ export function setOnUnauthorizedCallback(callback: () => void) {
|
||||
}
|
||||
|
||||
const API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api';
|
||||
const WELLNUO_API_URL = 'https://wellnuo.smartlaunchhub.com'; // New WellNuo backend
|
||||
const CLIENT_ID = 'MA_001';
|
||||
|
||||
// Avatar images for elderly beneficiaries - grandmothers (бабушки)
|
||||
@ -41,6 +42,51 @@ function formatTimeAgo(date: Date): string {
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
// Types for new auth flow
|
||||
interface OTPRequestResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
isNewUser: boolean;
|
||||
_devCode?: string; // Only in dev mode
|
||||
}
|
||||
|
||||
interface OTPVerifyResponse {
|
||||
success: boolean;
|
||||
token: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
phone: string | null;
|
||||
role: string;
|
||||
};
|
||||
beneficiaries: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
grantedAt: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
phone: string | null;
|
||||
role: string;
|
||||
};
|
||||
beneficiaries: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
grantedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private async getToken(): Promise<string | null> {
|
||||
try {
|
||||
@ -117,6 +163,196 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ NEW OTP AUTHENTICATION ============
|
||||
|
||||
// Request OTP code to be sent to email
|
||||
async requestOTP(email: string): Promise<ApiResponse<OTPRequestResponse>> {
|
||||
try {
|
||||
const response = await fetch(`${WELLNUO_API_URL}/api/auth/request-otp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
return { data, ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: data.error || 'Failed to send OTP',
|
||||
status: response.status,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Network error',
|
||||
code: 'NETWORK_ERROR',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Verify OTP code and get JWT token
|
||||
async verifyOTP(email: string, code: string): Promise<ApiResponse<OTPVerifyResponse>> {
|
||||
try {
|
||||
const response = await fetch(`${WELLNUO_API_URL}/api/auth/verify-otp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// Save new auth data
|
||||
await SecureStore.setItemAsync('accessToken', data.token);
|
||||
await SecureStore.setItemAsync('userId', data.user.id);
|
||||
await SecureStore.setItemAsync('userEmail', data.user.email);
|
||||
await SecureStore.setItemAsync('userName', data.user.email.split('@')[0]); // For legacy compatibility
|
||||
|
||||
if (data.user.firstName) {
|
||||
await SecureStore.setItemAsync('userFirstName', data.user.firstName);
|
||||
}
|
||||
if (data.user.lastName) {
|
||||
await SecureStore.setItemAsync('userLastName', data.user.lastName);
|
||||
}
|
||||
|
||||
return { data, ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: data.error || 'Invalid or expired code',
|
||||
status: response.status,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Network error',
|
||||
code: 'NETWORK_ERROR',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get current user info from JWT token
|
||||
async getMe(): Promise<ApiResponse<MeResponse>> {
|
||||
const token = await this.getToken();
|
||||
|
||||
if (!token) {
|
||||
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WELLNUO_API_URL}/api/auth/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.user) {
|
||||
return { data, ok: true };
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
if (onUnauthorizedCallback) {
|
||||
onUnauthorizedCallback();
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: { message: 'Session expired', code: 'UNAUTHORIZED', status: 401 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: data.error || 'Failed to get user info',
|
||||
status: response.status,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Network error',
|
||||
code: 'NETWORK_ERROR',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update user profile
|
||||
async updateProfile(data: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string;
|
||||
}): Promise<ApiResponse<{ success: boolean; user: MeResponse['user'] }>> {
|
||||
const token = await this.getToken();
|
||||
|
||||
if (!token) {
|
||||
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${WELLNUO_API_URL}/api/auth/profile`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
// Update local storage
|
||||
if (data.firstName) {
|
||||
await SecureStore.setItemAsync('userFirstName', data.firstName);
|
||||
}
|
||||
if (data.lastName) {
|
||||
await SecureStore.setItemAsync('userLastName', data.lastName);
|
||||
}
|
||||
return { data: result, ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: result.error || 'Failed to update profile',
|
||||
status: response.status,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Network error',
|
||||
code: 'NETWORK_ERROR',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============ LEGACY AUTHENTICATION ============
|
||||
|
||||
// Authentication
|
||||
async login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
|
||||
const response = await this.makeRequest<AuthResponse>({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user