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:
Sergei 2025-12-19 16:53:17 -08:00
parent 19d24e7b00
commit ddfe5c7bd6
5 changed files with 912 additions and 135 deletions

View 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,
},
});

View File

@ -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');
}
} catch (err) {
setError('Something went wrong. Please try again.');
} finally {
setIsLoading(false);
}
}, [username, password, login, clearError]);
const displayError = validationError || error?.message;
}, [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
View 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',
},
});

View File

@ -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();
setState({
user,
isLoading: false,
isAuthenticated: !!user,
error: null,
});
// Try to get user from new API
const response = await api.getMe();
if (response.ok && response.data) {
const userData = response.data.user;
setState({
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 {
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>
);

View File

@ -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>({