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,
|
Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { api } from '@/services/api';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
||||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const { login, isLoading, error, clearError } = useAuth();
|
const [email, setEmail] = useState('');
|
||||||
const [username, setUsername] = useState('');
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [password, setPassword] = useState('');
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleLogin = useCallback(async () => {
|
const validateEmail = (email: string): boolean => {
|
||||||
// Clear previous errors
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
clearError();
|
return emailRegex.test(email);
|
||||||
setValidationError(null);
|
};
|
||||||
|
|
||||||
// Validate
|
const handleContinue = useCallback(async () => {
|
||||||
if (!username.trim()) {
|
setError(null);
|
||||||
setValidationError('Username is required');
|
|
||||||
|
// Validate email
|
||||||
|
const trimmedEmail = email.trim().toLowerCase();
|
||||||
|
if (!trimmedEmail) {
|
||||||
|
setError('Please enter your email address');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!password.trim()) {
|
if (!validateEmail(trimmedEmail)) {
|
||||||
setValidationError('Password is required');
|
setError('Please enter a valid email address');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await login({ username: username.trim(), password });
|
setIsLoading(true);
|
||||||
|
|
||||||
if (success) {
|
try {
|
||||||
// Clear password from memory after successful login
|
const response = await api.requestOTP(trimmedEmail);
|
||||||
setPassword('');
|
|
||||||
router.replace('/(tabs)');
|
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');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [username, password, login, clearError]);
|
}, [email]);
|
||||||
|
|
||||||
const displayError = validationError || error?.message;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@ -65,70 +81,54 @@ export default function LoginScreen() {
|
|||||||
style={styles.logo}
|
style={styles.logo}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.title}>Welcome Back</Text>
|
<Text style={styles.title}>Welcome to WellNuo</Text>
|
||||||
<Text style={styles.subtitle}>Sign in to continue monitoring your loved ones</Text>
|
<Text style={styles.subtitle}>
|
||||||
|
Enter your email to get started. We'll send you a verification code.
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
{displayError && (
|
{error && (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={displayError}
|
message={error}
|
||||||
onDismiss={() => {
|
onDismiss={() => setError(null)}
|
||||||
clearError();
|
|
||||||
setValidationError(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Username"
|
label="Email Address"
|
||||||
placeholder="Enter your username"
|
placeholder="Enter your email"
|
||||||
leftIcon="person-outline"
|
leftIcon="mail-outline"
|
||||||
value={username}
|
value={email}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
setUsername(text);
|
setEmail(text);
|
||||||
setValidationError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
|
keyboardType="email-address"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
|
autoComplete="email"
|
||||||
editable={!isLoading}
|
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
|
<Button
|
||||||
title="Sign In"
|
title="Continue"
|
||||||
onPress={handleLogin}
|
onPress={handleContinue}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Info */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.infoContainer}>
|
||||||
<Text style={styles.footerText}>Don't have an account? </Text>
|
<Text style={styles.infoText}>
|
||||||
<TouchableOpacity onPress={() => router.push('/(auth)/register')}>
|
We'll send a 6-digit code to your email for verification.
|
||||||
<Text style={styles.footerLink}>Create Account</Text>
|
No password needed!
|
||||||
</TouchableOpacity>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Version Info */}
|
{/* Version Info */}
|
||||||
@ -168,34 +168,22 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
marginBottom: Spacing.xl,
|
marginBottom: Spacing.xl,
|
||||||
},
|
},
|
||||||
forgotPassword: {
|
infoContainer: {
|
||||||
alignSelf: 'flex-end',
|
backgroundColor: AppColors.surface,
|
||||||
marginBottom: Spacing.lg,
|
borderRadius: BorderRadius.lg,
|
||||||
marginTop: -Spacing.sm,
|
padding: Spacing.md,
|
||||||
},
|
|
||||||
forgotPasswordText: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: Spacing.xl,
|
marginBottom: Spacing.xl,
|
||||||
},
|
},
|
||||||
footerText: {
|
infoText: {
|
||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.sm,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
},
|
textAlign: 'center',
|
||||||
footerLink: {
|
lineHeight: 20,
|
||||||
fontSize: FontSizes.base,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
},
|
||||||
version: {
|
version: {
|
||||||
textAlign: 'center',
|
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 { 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 {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@ -10,9 +24,9 @@ interface AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextType extends AuthState {
|
interface AuthContextType extends AuthState {
|
||||||
login: (credentials: LoginCredentials) => Promise<boolean>;
|
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
refreshAuth: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
@ -48,13 +62,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const isAuth = await api.isAuthenticated();
|
const isAuth = await api.isAuthenticated();
|
||||||
if (isAuth) {
|
if (isAuth) {
|
||||||
const user = await api.getStoredUser();
|
// Try to get user from new API
|
||||||
setState({
|
const response = await api.getMe();
|
||||||
user,
|
if (response.ok && response.data) {
|
||||||
isLoading: false,
|
const userData = response.data.user;
|
||||||
isAuthenticated: !!user,
|
setState({
|
||||||
error: null,
|
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: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Token invalid or expired
|
||||||
|
await api.logout();
|
||||||
|
setState({
|
||||||
|
user: null,
|
||||||
|
isLoading: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({
|
||||||
user: null,
|
user: null,
|
||||||
@ -73,46 +109,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const login = useCallback(async (credentials: LoginCredentials): Promise<boolean> => {
|
const refreshAuth = useCallback(async () => {
|
||||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
await checkAuth();
|
||||||
|
|
||||||
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 logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
@ -135,7 +133,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ ...state, login, logout, clearError }}>
|
<AuthContext.Provider value={{ ...state, logout, clearError, refreshAuth }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</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 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';
|
const CLIENT_ID = 'MA_001';
|
||||||
|
|
||||||
// Avatar images for elderly beneficiaries - grandmothers (бабушки)
|
// Avatar images for elderly beneficiaries - grandmothers (бабушки)
|
||||||
@ -41,6 +42,51 @@ function formatTimeAgo(date: Date): string {
|
|||||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
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 {
|
class ApiService {
|
||||||
private async getToken(): Promise<string | null> {
|
private async getToken(): Promise<string | null> {
|
||||||
try {
|
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
|
// Authentication
|
||||||
async login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
|
async login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
|
||||||
const response = await this.makeRequest<AuthResponse>({
|
const response = await this.makeRequest<AuthResponse>({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user