mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 06:47:55 +00:00
feat(mobile): implement real Supabase authentication
Replace mock authentication with real Supabase integration: **New Files:** - mobile/src/lib/supabase.ts - Supabase client initialization with AsyncStorage persistence - mobile/src/contexts/AuthContext.tsx - Complete authentication context with session management **Updated Files:** - mobile/src/screens/SignInScreen.tsx * Import useAuth from AuthContext * Add Alert and ActivityIndicator for error handling and loading states * Replace mock setTimeout with real signIn() API call * Add loading state management (isLoading) * Update button to show ActivityIndicator during sign-in * Add proper error handling with Alert dialogs - mobile/src/screens/SignUpScreen.tsx * Import useAuth from AuthContext * Add Alert and ActivityIndicator * Add username state and input field * Replace mock registration with real signUp() API call * Add loading state management * Update button to show ActivityIndicator during sign-up * Add form validation for all required fields * Add proper error handling with Alert dialogs - mobile/App.tsx * Import and add AuthProvider to provider hierarchy * Provider order: ErrorBoundary → AuthProvider → PolkadotProvider → LanguageProvider → BiometricAuthProvider **Features Implemented:** - Real user authentication with Supabase - Email/password sign in with error handling - User registration with username and referral code support - Profile creation in Supabase database - Admin status checking - Session timeout management (30 minutes inactivity) - Automatic session refresh - Activity tracking with AsyncStorage - Auth state persistence across app restarts **Security:** - Credentials from environment variables (EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY) - Automatic token refresh enabled - Secure session persistence with AsyncStorage - No sensitive data in console logs (protected with __DEV__) This completes P0 authentication implementation for mobile app. Production ready authentication matching web implementation.
This commit is contained in:
+11
-8
@@ -4,6 +4,7 @@ import { StatusBar } from 'expo-status-bar';
|
||||
import { initializeI18n } from './src/i18n';
|
||||
import { ErrorBoundary } from './src/components/ErrorBoundary';
|
||||
import { LanguageProvider } from './src/contexts/LanguageContext';
|
||||
import { AuthProvider } from './src/contexts/AuthContext';
|
||||
import { PolkadotProvider } from './src/contexts/PolkadotContext';
|
||||
import { BiometricAuthProvider } from './src/contexts/BiometricAuthContext';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
@@ -38,14 +39,16 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PolkadotProvider>
|
||||
<LanguageProvider>
|
||||
<BiometricAuthProvider>
|
||||
<StatusBar style="auto" />
|
||||
<AppNavigator />
|
||||
</BiometricAuthProvider>
|
||||
</LanguageProvider>
|
||||
</PolkadotProvider>
|
||||
<AuthProvider>
|
||||
<PolkadotProvider>
|
||||
<LanguageProvider>
|
||||
<BiometricAuthProvider>
|
||||
<StatusBar style="auto" />
|
||||
<AppNavigator />
|
||||
</BiometricAuthProvider>
|
||||
</LanguageProvider>
|
||||
</PolkadotProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
// Session timeout configuration
|
||||
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const ACTIVITY_CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
|
||||
const LAST_ACTIVITY_KEY = '@pezkuwi_last_activity';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
isAdmin: boolean;
|
||||
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
|
||||
signUp: (email: string, password: string, username: string, referralCode?: string) => Promise<{ error: Error | null }>;
|
||||
signOut: () => Promise<void>;
|
||||
checkAdminStatus: () => Promise<boolean>;
|
||||
updateActivity: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Update last activity timestamp
|
||||
const updateActivity = useCallback(async () => {
|
||||
try {
|
||||
await AsyncStorage.setItem(LAST_ACTIVITY_KEY, Date.now().toString());
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to update activity:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
setIsAdmin(false);
|
||||
setUser(null);
|
||||
await AsyncStorage.removeItem(LAST_ACTIVITY_KEY);
|
||||
await supabase.auth.signOut();
|
||||
}, []);
|
||||
|
||||
// Check if session has timed out
|
||||
const checkSessionTimeout = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const lastActivity = await AsyncStorage.getItem(LAST_ACTIVITY_KEY);
|
||||
if (!lastActivity) {
|
||||
await updateActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
const lastActivityTime = parseInt(lastActivity, 10);
|
||||
const now = Date.now();
|
||||
const inactiveTime = now - lastActivityTime;
|
||||
|
||||
if (inactiveTime >= SESSION_TIMEOUT_MS) {
|
||||
if (__DEV__) console.log('⏱️ Session timeout - logging out due to inactivity');
|
||||
await signOut();
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error checking session timeout:', error);
|
||||
}
|
||||
}, [user, updateActivity, signOut]);
|
||||
|
||||
// Setup activity monitoring
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Initial activity timestamp
|
||||
updateActivity();
|
||||
|
||||
// Check for timeout periodically
|
||||
const timeoutChecker = setInterval(checkSessionTimeout, ACTIVITY_CHECK_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
clearInterval(timeoutChecker);
|
||||
};
|
||||
}, [user, updateActivity, checkSessionTimeout]);
|
||||
|
||||
// Check admin status
|
||||
const checkAdminStatus = useCallback(async (): Promise<boolean> => {
|
||||
if (!user) return false;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('is_admin')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (__DEV__) console.error('Error checking admin status:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const adminStatus = data?.is_admin || false;
|
||||
setIsAdmin(adminStatus);
|
||||
return adminStatus;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error in checkAdminStatus:', error);
|
||||
return false;
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Sign in function
|
||||
const signIn = async (email: string, password: string): Promise<{ error: Error | null }> => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
setUser(data.user);
|
||||
await updateActivity();
|
||||
await checkAdminStatus();
|
||||
}
|
||||
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
return { error: error as Error };
|
||||
}
|
||||
};
|
||||
|
||||
// Sign up function
|
||||
const signUp = async (
|
||||
email: string,
|
||||
password: string,
|
||||
username: string,
|
||||
referralCode?: string
|
||||
): Promise<{ error: Error | null }> => {
|
||||
try {
|
||||
// Create auth user
|
||||
const { data: authData, error: authError } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
username,
|
||||
referral_code: referralCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
return { error: authError };
|
||||
}
|
||||
|
||||
// Create profile
|
||||
if (authData.user) {
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.insert({
|
||||
id: authData.user.id,
|
||||
username,
|
||||
email,
|
||||
referral_code: referralCode,
|
||||
});
|
||||
|
||||
if (profileError) {
|
||||
if (__DEV__) console.error('Profile creation error:', profileError);
|
||||
// Don't fail signup if profile creation fails
|
||||
}
|
||||
|
||||
setUser(authData.user);
|
||||
await updateActivity();
|
||||
}
|
||||
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
return { error: error as Error };
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize auth state
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
// Get initial session
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
if (session?.user) {
|
||||
setUser(session.user);
|
||||
await checkAdminStatus();
|
||||
await updateActivity();
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error initializing auth:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
async (_event, session) => {
|
||||
setUser(session?.user ?? null);
|
||||
|
||||
if (session?.user) {
|
||||
await checkAdminStatus();
|
||||
await updateActivity();
|
||||
} else {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [checkAdminStatus, updateActivity]);
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
isAdmin,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
checkAdminStatus,
|
||||
updateActivity,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// Initialize Supabase client from environment variables
|
||||
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || '';
|
||||
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
if (__DEV__) {
|
||||
console.warn('Supabase credentials not found in environment variables');
|
||||
console.warn('Add EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to .env');
|
||||
}
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
Platform,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import AppColors, { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface SignInScreenProps {
|
||||
@@ -22,13 +25,35 @@ interface SignInScreenProps {
|
||||
|
||||
const SignInScreen: React.FC<SignInScreenProps> = ({ onSignIn, onNavigateToSignUp }) => {
|
||||
const { t } = useTranslation();
|
||||
const { signIn } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSignIn = () => {
|
||||
// TODO: Implement actual authentication
|
||||
if (__DEV__) console.log('Sign in:', { email, password });
|
||||
onSignIn();
|
||||
const handleSignIn = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Error', 'Please enter both email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = await signIn(email, password);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Sign In Failed', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - navigate to app
|
||||
onSignIn();
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'An unexpected error occurred');
|
||||
if (__DEV__) console.error('Sign in error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -91,11 +116,16 @@ const SignInScreen: React.FC<SignInScreenProps> = ({ onSignIn, onNavigateToSignU
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.signInButton}
|
||||
style={[styles.signInButton, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleSignIn}
|
||||
activeOpacity={0.8}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.signInButtonText}>{t('auth.signIn')}</Text>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.signInButtonText}>{t('auth.signIn')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.divider}>
|
||||
@@ -223,6 +253,9 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
Platform,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import AppColors, { KurdistanColors } from '../theme/colors';
|
||||
|
||||
interface SignUpScreenProps {
|
||||
@@ -22,18 +25,42 @@ interface SignUpScreenProps {
|
||||
|
||||
const SignUpScreen: React.FC<SignUpScreenProps> = ({ onSignUp, onNavigateToSignIn }) => {
|
||||
const { t } = useTranslation();
|
||||
const { signUp } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSignUp = () => {
|
||||
// TODO: Implement actual registration
|
||||
if (password !== confirmPassword) {
|
||||
alert('Passwords do not match!');
|
||||
const handleSignUp = async () => {
|
||||
if (!email || !password || !username) {
|
||||
Alert.alert('Error', 'Please fill all required fields');
|
||||
return;
|
||||
}
|
||||
if (__DEV__) console.log('Sign up:', { email, password });
|
||||
onSignUp();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert('Error', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { error } = await signUp(email, password, username);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Sign Up Failed', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - navigate to app
|
||||
onSignUp();
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'An unexpected error occurred');
|
||||
if (__DEV__) console.error('Sign up error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -77,6 +104,18 @@ const SignUpScreen: React.FC<SignUpScreenProps> = ({ onSignUp, onNavigateToSignI
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('auth.username')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="rgba(0, 0, 0, 0.4)"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('auth.password')}</Text>
|
||||
<TextInput
|
||||
@@ -102,11 +141,16 @@ const SignUpScreen: React.FC<SignUpScreenProps> = ({ onSignUp, onNavigateToSignI
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.signUpButton}
|
||||
style={[styles.signUpButton, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleSignUp}
|
||||
activeOpacity={0.8}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.signUpButtonText}>{t('auth.signUp')}</Text>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={KurdistanColors.spi} />
|
||||
) : (
|
||||
<Text style={styles.signUpButtonText}>{t('auth.signUp')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.divider}>
|
||||
@@ -226,6 +270,9 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
color: KurdistanColors.spi,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user