š Overview
In this part, we'll implement:
- Zustand store for authentication state
- Login screen with form validation
- Auth guard for protected routes
- Secure token storage with SecureStore
šļø Step 1: Create Auth Store
Zustand is a lightweight state management library that's perfect for React Native.
File: store/auth.ts
import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
// Types
interface User {
id: string;
name: string;
email: string;
role: string;
picture?: string;
}
interface AuthState {
token: string | null;
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
// Actions
setAuth: (token: string, user: User) => Promise<void>;
logout: () => Promise<void>;
initialize: () => Promise<void>;
}
// Store
export const useAuthStore = create<AuthState>((set, get) => ({
token: null,
user: null,
isLoading: true,
// Computed property
get isAuthenticated() {
return !!get().token;
},
// Set authentication data
setAuth: async (token, user) => {
try {
await SecureStore.setItemAsync('auth_token', token);
await SecureStore.setItemAsync('auth_user', JSON.stringify(user));
set({ token, user, isLoading: false });
} catch (error) {
console.error('Error saving auth:', error);
throw error;
}
},
// Logout - clear everything
logout: async () => {
try {
await SecureStore.deleteItemAsync('auth_token');
await SecureStore.deleteItemAsync('auth_user');
set({ token: null, user: null, isLoading: false });
} catch (error) {
console.error('Error during logout:', error);
set({ token: null, user: null, isLoading: false });
}
},
// Initialize - load from storage
initialize: async () => {
try {
const token = await SecureStore.getItemAsync('auth_token');
const userStr = await SecureStore.getItemAsync('auth_user');
if (token && userStr) {
const user = JSON.parse(userStr);
set({ token, user, isLoading: false });
} else {
set({ isLoading: false });
}
} catch (error) {
console.error('Failed to initialize auth:', error);
set({ isLoading: false });
}
},
}));
File: store/index.ts
export { useAuthStore } from './auth';
š± Step 2: Create Login Screen
File: app/login.tsx
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
Image,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Mail, Lock, LogIn } from 'lucide-react-native';
import { loginAdmin } from '@/services/auth';
import { useAuthStore } from '@/store/auth';
export default function LoginScreen() {
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
// Validate form
const validate = (): boolean => {
const newErrors: { email?: string; password?: string } = {};
if (!email.trim()) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Invalid email format';
}
if (!password) {
newErrors.password = 'Password is required';
} else if (password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle login
const handleLogin = async () => {
if (!validate()) return;
setIsLoading(true);
try {
const response = await loginAdmin(email, password);
if (response.success && response.data) {
await setAuth(response.data.token, response.data.user);
router.replace('/(tabs)');
} else {
Alert.alert('Login Failed', response.error || 'Invalid credentials');
}
} catch (error: any) {
console.error('Login error:', error);
Alert.alert('Error', 'An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1 bg-gray-900"
>
<View className="flex-1 justify-center px-6">
{/* Logo */}
<View className="items-center mb-10">
<Image
source={require('@/assets/images/icon.png')}
className="w-24 h-24 rounded-2xl"
/>
<Text className="text-3xl font-bold text-white mt-4">
Lampung Dev
</Text>
<Text className="text-gray-400 mt-2">
Admin Portal
</Text>
</View>
{/* Form */}
<View className="space-y-4">
{/* Email Input */}
<View>
<View className="flex-row items-center bg-gray-800 rounded-xl px-4 py-3">
<Mail size={20} color="#9ca3af" />
<TextInput
className="flex-1 ml-3 text-white text-base"
placeholder="Email"
placeholderTextColor="#6b7280"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{errors.email && (
<Text className="text-red-500 text-sm mt-1 ml-1">
{errors.email}
</Text>
)}
</View>
{/* Password Input */}
<View className="mt-4">
<View className="flex-row items-center bg-gray-800 rounded-xl px-4 py-3">
<Lock size={20} color="#9ca3af" />
<TextInput
className="flex-1 ml-3 text-white text-base"
placeholder="Password"
placeholderTextColor="#6b7280"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
</View>
{errors.password && (
<Text className="text-red-500 text-sm mt-1 ml-1">
{errors.password}
</Text>
)}
</View>
{/* Login Button */}
<TouchableOpacity
className={`flex-row items-center justify-center py-4 rounded-xl mt-6 ${
isLoading ? 'bg-orange-400' : 'bg-orange-500'
}`}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="white" />
) : (
<>
<LogIn size={20} color="white" />
<Text className="text-white font-semibold text-lg ml-2">
Login
</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
);
}
š”ļø Step 3: Setup Auth Guard
File: app/_layout.tsx
import React, { useEffect } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { View, ActivityIndicator } from 'react-native';
import { useAuthStore } from '@/store/auth';
import '../global.css';
function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const segments = useSegments();
const { token, isLoading, initialize } = useAuthStore();
useEffect(() => {
initialize();
}, []);
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === 'login';
const isAuthenticated = !!token;
if (!isAuthenticated && !inAuthGroup) {
router.replace('/login');
} else if (isAuthenticated && inAuthGroup) {
router.replace('/(tabs)');
}
}, [token, isLoading, segments]);
if (isLoading) {
return (
<View className="flex-1 bg-gray-900 items-center justify-center">
<ActivityIndicator size="large" color="#f0880a" />
</View>
);
}
return <>{children}</>;
}
export default function RootLayout() {
return (
<AuthGuard>
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#111827' },
animation: 'slide_from_right',
}}
>
<Stack.Screen name="login" />
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="event/[id]"
options={{
headerShown: true,
headerTitle: 'Event Detail',
headerStyle: { backgroundColor: '#111827' },
headerTintColor: '#fff',
}}
/>
</Stack>
</AuthGuard>
);
}
š Auth Flow Diagram
App Initialization
| Step | Action | Result |
|---|---|---|
| 1ļøā£ | App starts | Trigger initialize() |
| 2ļøā£ | Load from SecureStore | Check for saved token & user |
| 3ļøā£ | Token found? | Yes ā Go to Home (Tabs) |
| 4ļøā£ | No token? | No ā Go to Login Screen |
Login Flow
| Step | Action | Result |
|---|---|---|
| 1ļøā£ | User enters credentials | Email & password input |
| 2ļøā£ | Form validation | Check required fields & format |
| 3ļøā£ | Call loginAdmin() | API POST to /api/admin/auth |
| 4ļøā£ | API validates | Server checks credentials |
| 5ļøā£ | Receive response | { token, user } on success |
| 6ļøā£ | Call setAuth() | Save to SecureStore (encrypted) |
| 7ļøā£ | Navigate to Home | Redirect to /(tabs) |
Logout Flow
| Step | Action | Result |
|---|---|---|
| 1ļøā£ | User clicks logout | Trigger logout() |
| 2ļøā£ | Clear SecureStore | Delete token & user data |
| 3ļøā£ | Reset state | Set token & user to null |
| 4ļøā£ | Auth guard detects | No token found |
| 5ļøā£ | Redirect to Login | Navigate to /login |
š Security Best Practices
| ā DO | ā DON'T |
|---|---|
| Use SecureStore for tokens | Store tokens in AsyncStorage |
| Validate input before API | Log sensitive data |
| Clear tokens on logout | Store passwords locally |
| Handle 401 globally | Trust client-side only |
| Use HTTPS in production | Skip server validation |
ā Checklist
- Auth store created with Zustand
- Login screen with form validation
- Auth guard in root layout
- SecureStore for token storage
- Auto-redirect based on auth state
š Next Steps
In Part 5, we'll implement event management with event list, event detail, and participant list.
ā Part 3: Project Setup | Next: Event Management ā
This series is created to support the Casual Meetup #15 at LampungDev.
