-
Notifications
You must be signed in to change notification settings - Fork 0
React Native 6. Guard
IOF edited this page Mar 8, 2026
·
1 revision
В этом спринте мы реализуем защиту маршрутов (Auth Guard) и защиту от несохраненных изменений (CanDeactivate) в React Native приложении.
npm install @react-native-async-storage/async-storage
npm install react-native-vector-icons
npm install @react-navigation/native @react-navigation/native-stack
# Для iOS
cd ios && pod install && cd ..src/components/guards/AuthGuard.tsx:
import React, { useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ActivityIndicator,
Alert
} from 'react-native';
import { useAuth } from '../../contexts/AuthContext';
interface AuthGuardProps {
children: React.ReactNode;
navigation: any;
redirectTo?: string;
}
export const AuthGuard: React.FC<AuthGuardProps> = ({
children,
navigation,
redirectTo = 'Auth'
}) => {
const { currentUser, loading } = useAuth();
useEffect(() => {
if (!loading && !currentUser) {
// Показываем уведомление
Alert.alert(
'Доступ запрещен',
'Для доступа к этой странице необходимо авторизоваться',
[{ text: 'OK' }]
);
// Перенаправляем на страницу авторизации
navigation.replace(redirectTo);
}
}, [currentUser, loading, navigation, redirectTo]);
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3f51b5" />
<Text style={styles.loadingText}>Проверка авторизации...</Text>
</View>
);
}
return currentUser ? <>{children}</> : null;
};
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
loadingText: {
marginTop: 12,
fontSize: 14,
color: '#666',
},
});src/components/guards/PreventUnsavedChanges.tsx:
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
BackHandler,
View,
Text,
StyleSheet,
Modal,
TouchableOpacity
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
interface PreventUnsavedChangesProps {
when: boolean;
message?: string;
children: React.ReactNode;
onConfirm?: () => void;
onCancel?: () => void;
title?: string;
}
export const PreventUnsavedChanges: React.FC<PreventUnsavedChangesProps> = ({
when,
message = 'У вас есть несохраненные изменения. Вы действительно хотите покинуть страницу?',
children,
onConfirm,
onCancel,
title = 'Несохраненные изменения'
}) => {
const navigation = useNavigation();
const [showDialog, setShowDialog] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null);
// Перехватываем аппаратную кнопку "Назад" на Android
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (when) {
setShowDialog(true);
return true; // Предотвращаем стандартное поведение
}
return false;
});
return () => backHandler.remove();
}, [when]);
// Перехватываем навигацию внутри приложения
useFocusEffect(
React.useCallback(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
if (!when) {
return;
}
// Предотвращаем навигацию
e.preventDefault();
// Показываем диалог подтверждения
setShowDialog(true);
setPendingNavigation(() => () => navigation.dispatch(e.data.action));
});
return unsubscribe;
}, [navigation, when])
);
const handleConfirm = () => {
setShowDialog(false);
if (onConfirm) {
onConfirm();
}
if (pendingNavigation) {
pendingNavigation();
}
setPendingNavigation(null);
};
const handleCancel = () => {
setShowDialog(false);
setPendingNavigation(null);
if (onCancel) {
onCancel();
}
};
return (
<>
{children}
<Modal
visible={showDialog}
transparent
animationType="fade"
onRequestClose={handleCancel}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Icon name="warning" size={30} color="#ff9800" />
<Text style={styles.modalTitle}>{title}</Text>
</View>
<Text style={styles.modalMessage}>{message}</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={handleCancel}
>
<Text style={styles.cancelButtonText}>Отмена</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.confirmButton]}
onPress={handleConfirm}
>
<Text style={styles.confirmButtonText}>Покинуть страницу</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</>
);
};
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
width: '80%',
maxWidth: 400,
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
gap: 12,
},
modalTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
flex: 1,
},
modalMessage: {
fontSize: 14,
color: '#666',
marginBottom: 20,
lineHeight: 20,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
},
modalButton: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 8,
minWidth: 100,
alignItems: 'center',
},
cancelButton: {
backgroundColor: '#f5f5f5',
},
cancelButtonText: {
color: '#666',
fontSize: 14,
fontWeight: '500',
},
confirmButton: {
backgroundColor: '#f44336',
},
confirmButtonText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
});src/hooks/usePreventUnsavedChanges.ts:
import { useState, useCallback, useEffect } from 'react';
import { Alert, BackHandler } from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
interface UsePreventUnsavedChangesProps {
isDirty: boolean;
message?: string;
title?: string;
}
export const usePreventUnsavedChanges = ({
isDirty,
message = 'У вас есть несохраненные изменения. Вы действительно хотите покинуть страницу?',
title = 'Несохраненные изменения'
}: UsePreventUnsavedChangesProps) => {
const navigation = useNavigation();
const [showDialog, setShowDialog] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null);
// Перехватываем аппаратную кнопку "Назад" на Android
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (isDirty) {
setShowDialog(true);
return true; // Предотвращаем стандартное поведение
}
return false;
});
return () => backHandler.remove();
}, [isDirty]);
// Перехватываем навигацию внутри приложения
useFocusEffect(
useCallback(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
if (!isDirty) {
return;
}
// Предотвращаем навигацию
e.preventDefault();
// Показываем Alert
Alert.alert(
title,
message,
[
{
text: 'Отмена',
style: 'cancel',
onPress: () => {}
},
{
text: 'Покинуть страницу',
style: 'destructive',
onPress: () => navigation.dispatch(e.data.action)
}
]
);
});
return unsubscribe;
}, [navigation, isDirty, message, title])
);
const confirmNavigation = useCallback(() => {
setShowDialog(false);
if (pendingNavigation) {
pendingNavigation();
}
setPendingNavigation(null);
}, [pendingNavigation]);
const cancelNavigation = useCallback(() => {
setShowDialog(false);
setPendingNavigation(null);
}, []);
return {
showDialog,
confirmNavigation,
cancelNavigation,
isDirty
};
};src/screens/SignScreen.tsx:
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
TouchableOpacity,
ActivityIndicator,
Alert
} from 'react-native';
import { useForm, Controller } from 'react-hook-form';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { useAuth } from '../contexts/AuthContext';
import { FormInput } from '../components/FormInput';
import { PreventUnsavedChanges } from '../components/guards/PreventUnsavedChanges';
import { validateLogin, validatePasswordLength, validateName } from '../validators/customValidators';
interface SignFormData {
login: string;
password: string;
name: string;
}
interface SignScreenProps {
navigation: any;
}
const SignScreen: React.FC<SignScreenProps> = ({ navigation }) => {
const [loading, setLoading] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const { register: registerUser } = useAuth();
const {
control,
handleSubmit,
watch,
formState: { errors, isValid, isDirty, touchedFields },
setError,
clearErrors,
reset
} = useForm<SignFormData>({
mode: 'onChange',
defaultValues: {
login: '',
password: '',
name: ''
}
});
const loginValue = watch('login');
const passwordValue = watch('password');
// Очистка ошибки для логина при изменении
useEffect(() => {
if (loginValue !== 'admin') {
clearErrors('login');
}
}, [loginValue, clearErrors]);
const onSubmit = async (data: SignFormData) => {
try {
setLoading(true);
setServerError(null);
await registerUser(data);
reset(); // Сбрасываем форму после успешной регистрации
navigation.replace('Auth', { registered: true });
} catch (err: any) {
const errors = err.response?.data?.errors;
if (errors) {
if (errors.Login) {
setError('login', {
type: 'manual',
message: errors.Login[0]
});
}
if (errors.Password) {
setError('password', {
type: 'manual',
message: errors.Password[0]
});
}
setServerError('Проверьте правильность заполнения полей');
} else {
setServerError(err.response?.data?.message || 'Ошибка регистрации');
}
} finally {
setLoading(false);
}
};
return (
<PreventUnsavedChanges
when={isDirty && !loading}
message="У вас есть несохраненные изменения. Вы действительно хотите покинуть страницу?"
title="Несохраненные изменения"
>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<View style={styles.header}>
<Icon name="person-add" size={60} color="#3f51b5" />
<Text style={styles.title}>Регистрация</Text>
<Text style={styles.subtitle}>Создайте новый аккаунт</Text>
</View>
<View style={styles.form}>
{serverError && (
<View style={styles.serverErrorContainer}>
<Icon name="error" size={16} color="#f44336" />
<Text style={styles.serverErrorText}>{serverError}</Text>
</View>
)}
{/* Поле Name */}
<Controller
control={control}
name="name"
rules={{ validate: validateName }}
render={({ field: { onChange, onBlur, value } }) => (
<FormInput
label="Имя"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.name?.message}
touched={touchedFields.name}
autoCapitalize="words"
icon="badge"
/>
)}
/>
{/* Поле Login */}
<Controller
control={control}
name="login"
rules={{ validate: validateLogin }}
render={({ field: { onChange, onBlur, value } }) => (
<FormInput
label="Логин"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.login?.message}
touched={touchedFields.login}
required
icon="person"
/>
)}
/>
{/* Дополнительное предупреждение для admin */}
{loginValue === 'admin' && touchedFields.login && !errors.login && (
<View style={styles.warningContainer}>
<Icon name="warning" size={16} color="#ff9800" />
<Text style={styles.warningText}>
Недопустимый логин пользователя!
</Text>
</View>
)}
{/* Поле Password */}
<Controller
control={control}
name="password"
rules={{ validate: validatePasswordLength }}
render={({ field: { onChange, onBlur, value } }) => (
<FormInput
label="Пароль"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.password?.message}
touched={touchedFields.password}
secureTextEntry
required
icon="lock"
/>
)}
/>
{/* Индикатор несохраненных изменений */}
{isDirty && (
<View style={styles.dirtyIndicator}>
<Icon name="info" size={16} color="#2196f3" />
<Text style={styles.dirtyText}>
✏️ У вас есть несохраненные изменения
</Text>
</View>
)}
<TouchableOpacity
style={[styles.button, (!isValid || loading) && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={!isValid || loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Зарегистрироваться</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => {
if (isDirty) {
Alert.alert(
'Несохраненные изменения',
'У вас есть несохраненные изменения. Вы действительно хотите покинуть страницу?',
[
{
text: 'Отмена',
style: 'cancel'
},
{
text: 'Покинуть',
onPress: () => navigation.navigate('Auth')
}
]
);
} else {
navigation.navigate('Auth');
}
}}
>
<Text style={styles.linkText}>
Уже есть аккаунт? Войти
</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</PreventUnsavedChanges>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollContent: {
flexGrow: 1,
},
content: {
flex: 1,
padding: 20,
},
header: {
alignItems: 'center',
marginBottom: 30,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#333',
marginTop: 16,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
},
form: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
serverErrorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#ffebee',
padding: 12,
borderRadius: 8,
marginBottom: 16,
},
serverErrorText: {
color: '#f44336',
fontSize: 14,
marginLeft: 8,
flex: 1,
},
warningContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff3e0',
padding: 10,
borderRadius: 8,
marginBottom: 16,
},
warningText: {
color: '#ff9800',
fontSize: 12,
marginLeft: 8,
flex: 1,
},
dirtyIndicator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#e3f2fd',
padding: 10,
borderRadius: 8,
marginBottom: 16,
},
dirtyText: {
color: '#2196f3',
fontSize: 12,
marginLeft: 8,
},
button: {
backgroundColor: '#3f51b5',
borderRadius: 8,
height: 48,
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
backgroundColor: '#9fa8da',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: 16,
alignItems: 'center',
},
linkText: {
color: '#3f51b5',
fontSize: 14,
},
});
export default SignScreen;src/screens/AuthScreen.tsx:
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
TouchableOpacity,
ActivityIndicator,
Alert
} from 'react-native';
import { useForm, Controller } from 'react-hook-form';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { useAuth } from '../contexts/AuthContext';
import { FormInput } from '../components/FormInput';
interface AuthFormData {
login: string;
password: string;
}
interface AuthScreenProps {
navigation: any;
route: any;
}
const AuthScreen: React.FC<AuthScreenProps> = ({ navigation, route }) => {
const [loading, setLoading] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const { login, currentUser } = useAuth();
// Получаем URL для редиректа после авторизации
const returnUrl = route.params?.returnUrl || 'Home';
useEffect(() => {
if (route.params?.registered) {
setSuccessMessage('Регистрация успешна! Теперь вы можете войти.');
}
if (currentUser) {
navigation.replace(returnUrl);
}
}, [route.params, currentUser, navigation, returnUrl]);
const {
control,
handleSubmit,
formState: { errors, isValid, touchedFields }
} = useForm<AuthFormData>({
mode: 'onChange',
defaultValues: {
login: '',
password: ''
}
});
const validateLogin = (value: string) => {
if (!value) return 'Логин обязателен';
return undefined;
};
const validatePassword = (value: string) => {
if (!value) return 'Пароль обязателен';
if (value.length < 3) return 'Пароль должен содержать минимум 3 символа';
return undefined;
};
const onSubmit = async (data: AuthFormData) => {
try {
setLoading(true);
await login(data.login, data.password);
// Редирект произойдет в useEffect
} catch (error) {
// Ошибка уже обработана в контексте
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.content}>
<View style={styles.header}>
<Icon name="lock" size={60} color="#3f51b5" />
<Text style={styles.title}>Авторизация</Text>
<Text style={styles.subtitle}>Войдите в свой аккаунт</Text>
</View>
<View style={styles.form}>
{successMessage && (
<View style={styles.successContainer}>
<Icon name="check-circle" size={16} color="#4caf50" />
<Text style={styles.successText}>{successMessage}</Text>
</View>
)}
{returnUrl !== 'Home' && (
<View style={styles.infoContainer}>
<Icon name="info" size={16} color="#2196f3" />
<Text style={styles.infoText}>
Для доступа к запрошенной странице необходимо войти в систему
</Text>
</View>
)}
{/* Поле Login */}
<Controller
control={control}
name="login"
rules={{ validate: validateLogin }}
render={({ field: { onChange, onBlur, value } }) => (
<FormInput
label="Логин"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.login?.message}
touched={touchedFields.login}
required
icon="person"
/>
)}
/>
{/* Поле Password */}
<Controller
control={control}
name="password"
rules={{ validate: validatePassword }}
render={({ field: { onChange, onBlur, value } }) => (
<FormInput
label="Пароль"
value={value}
onChangeText={onChange}
onBlur={onBlur}
error={errors.password?.message}
touched={touchedFields.password}
secureTextEntry
required
icon="lock"
/>
)}
/>
<TouchableOpacity
style={[styles.button, (!isValid || loading) && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={!isValid || loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Войти</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.navigate('Sign')}
>
<Text style={styles.linkText}>
Нет аккаунта? Зарегистрироваться
</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollContent: {
flexGrow: 1,
},
content: {
flex: 1,
justifyContent: 'center',
padding: 20,
},
header: {
alignItems: 'center',
marginBottom: 40,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: '#333',
marginTop: 16,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
},
form: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 20,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
successContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#e8f5e8',
padding: 12,
borderRadius: 8,
marginBottom: 16,
},
successText: {
color: '#4caf50',
fontSize: 14,
marginLeft: 8,
flex: 1,
},
infoContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#e3f2fd',
padding: 12,
borderRadius: 8,
marginBottom: 16,
},
infoText: {
color: '#2196f3',
fontSize: 14,
marginLeft: 8,
flex: 1,
},
button: {
backgroundColor: '#3f51b5',
borderRadius: 8,
height: 48,
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
backgroundColor: '#9fa8da',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: 16,
alignItems: 'center',
},
linkText: {
color: '#3f51b5',
fontSize: 14,
},
});
export default AuthScreen;src/screens/UsersScreen.tsx:
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
Alert,
TouchableOpacity
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { usersService } from '../services/users.service';
import { useAuth } from '../contexts/AuthContext';
import { AuthGuard } from '../components/guards/AuthGuard';
import { LoadingSpinner } from '../components/LoadingSpinner';
import User from '../models/user.entity';
interface UsersScreenProps {
navigation: any;
}
const UsersContent: React.FC<UsersScreenProps> = ({ navigation }) => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const { currentUser, token } = useAuth();
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setError(null);
const data = await usersService.getAll();
setUsers(data);
} catch (err: any) {
if (err.response?.status === 401) {
setError('Сессия истекла. Пожалуйста, войдите в систему заново.');
// Через 2 секунды перенаправляем на вход
setTimeout(() => {
navigation.replace('Auth', { returnUrl: 'Users' });
}, 2000);
} else {
setError('Не удалось загрузить пользователей');
}
console.error(err);
} finally {
setLoading(false);
}
};
const onRefresh = async () => {
setRefreshing(true);
await loadUsers();
setRefreshing(false);
};
const handleUserPress = (user: User) => {
navigation.navigate('Profile', { userId: user.id });
};
const renderUserItem = ({ item }: { item: User }) => (
<TouchableOpacity
style={styles.userCard}
onPress={() => handleUserPress(item)}
activeOpacity={0.7}
>
<View style={[styles.userAvatar, currentUser?.id === item.id && styles.currentUserAvatar]}>
<Text style={styles.userAvatarText}>
{item.name?.charAt(0).toUpperCase() || 'U'}
</Text>
{currentUser?.id === item.id && (
<View style={styles.currentUserBadge}>
<Icon name="star" size={12} color="#fff" />
</View>
)}
</View>
<View style={styles.userInfo}>
<Text style={styles.userName}>{item.name || 'Без имени'}</Text>
<Text style={styles.userLogin}>{item.login || 'Нет логина'}</Text>
<Text style={styles.userId}>ID: {item.id}</Text>
</View>
{item.token && (
<View style={styles.tokenBadge}>
<Icon name="vpn-key" size={16} color="#4caf50" />
</View>
)}
<Icon name="chevron-right" size={24} color="#999" />
</TouchableOpacity>
);
const renderHeader = () => (
<View style={styles.header}>
<Text style={styles.headerTitle}>
Всего пользователей: {users.length}
</Text>
{token && (
<View style={styles.tokenInfo}>
<Icon name="vpn-key" size={16} color="#4caf50" />
<Text style={styles.tokenText}>JWT активен</Text>
</View>
)}
</View>
);
if (loading) {
return <LoadingSpinner fullScreen text="Загрузка пользователей..." />;
}
if (error) {
return (
<View style={styles.errorContainer}>
<Icon name="error-outline" size={60} color="#f44336" />
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadUsers}>
<Text style={styles.retryText}>Повторить</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={users}
keyExtractor={(item) => item.id.toString()}
renderItem={renderUserItem}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListHeaderComponent={renderHeader}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Icon name="people-outline" size={80} color="#ccc" />
<Text style={styles.emptyText}>Нет пользователей</Text>
</View>
}
/>
</View>
);
};
const UsersScreen: React.FC<UsersScreenProps> = ({ navigation }) => {
return (
<AuthGuard navigation={navigation} redirectTo="Auth">
<UsersContent navigation={navigation} />
</AuthGuard>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
listContent: {
padding: 16,
flexGrow: 1,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
headerTitle: {
fontSize: 16,
color: '#666',
},
tokenInfo: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#e8f5e8',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
gap: 4,
},
tokenText: {
fontSize: 12,
color: '#4caf50',
},
userCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 8,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
},
userAvatar: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: '#3f51b5',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
position: 'relative',
},
currentUserAvatar: {
backgroundColor: '#f50057',
},
userAvatarText: {
color: '#fff',
fontSize: 20,
fontWeight: 'bold',
},
currentUserBadge: {
position: 'absolute',
bottom: -2,
right: -2,
backgroundColor: '#ffc107',
borderRadius: 8,
width: 16,
height: 16,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#fff',
},
userInfo: {
flex: 1,
},
userName: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 4,
},
userLogin: {
fontSize: 14,
color: '#666',
marginBottom: 2,
},
userId: {
fontSize: 12,
color: '#999',
},
tokenBadge: {
marginRight: 8,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 40,
},
emptyText: {
fontSize: 16,
color: '#999',
marginTop: 16,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
color: '#f44336',
textAlign: 'center',
marginTop: 16,
marginBottom: 16,
},
retryButton: {
backgroundColor: '#3f51b5',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
});
export default UsersScreen;src/screens/ProfileScreen.tsx:
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
Share
} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import { usersService } from '../services/users.service';
import { useAuth } from '../contexts/AuthContext';
import { AuthGuard } from '../components/guards/AuthGuard';
import { LoadingSpinner } from '../components/LoadingSpinner';
import User from '../models/user.entity';
interface ProfileScreenProps {
navigation: any;
route: any;
}
const ProfileContent: React.FC<ProfileScreenProps> = ({ navigation, route }) => {
const { userId } = route.params;
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { currentUser, token } = useAuth();
useEffect(() => {
loadUser();
}, [userId]);
const loadUser = async () => {
try {
setLoading(true);
setError(null);
const data = await usersService.get(userId);
setUser(data);
} catch (err) {
setError('Не удалось загрузить профиль пользователя');
console.error(err);
} finally {
setLoading(false);
}
};
const handleShare = async () => {
try {
await Share.share({
message: `Пользователь: ${user?.name}\nЛогин: ${user?.login}\nID: ${user?.id}`,
title: 'Профиль пользователя',
});
} catch (error) {
console.error(error);
}
};
const isOwnProfile = currentUser?.id === user?.id;
if (loading) {
return <LoadingSpinner fullScreen text="Загрузка профиля..." />;
}
if (error || !user) {
return (
<View style={styles.errorContainer}>
<Icon name="error-outline" size={60} color="#f44336" />
<Text style={styles.errorText}>{error || 'Пользователь не найден'}</Text>
<TouchableOpacity style={styles.retryButton} onPress={loadUser}>
<Text style={styles.retryText}>Повторить</Text>
</TouchableOpacity>
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.content}>
{/* Шапка профиля */}
<View style={styles.header}>
<View style={[styles.avatar, isOwnProfile && styles.ownAvatar]}>
<Text style={styles.avatarText}>
{user.name?.charAt(0).toUpperCase() || user.login.charAt(0).toUpperCase()}
</Text>
{isOwnProfile && (
<View style={styles.ownBadge}>
<Icon name="star" size={16} color="#fff" />
</View>
)}
</View>
<Text style={styles.name}>{user.name || 'Без имени'}</Text>
<Text style={styles.login}>@{user.login}</Text>
{isOwnProfile && token && (
<View style={styles.tokenContainer}>
<Icon name="vpn-key" size={16} color="#4caf50" />
<Text style={styles.tokenText}>JWT токен активен</Text>
</View>
)}
</View>
{/* Информация о пользователе */}
<View style={styles.infoCard}>
<Text style={styles.infoTitle}>Информация</Text>
<View style={styles.infoRow}>
<Icon name="badge" size={20} color="#666" />
<Text style={styles.infoLabel}>ID:</Text>
<Text style={styles.infoValue}>{user.id}</Text>
</View>
<View style={styles.infoRow}>
<Icon name="person" size={20} color="#666" />
<Text style={styles.infoLabel}>Логин:</Text>
<Text style={styles.infoValue}>{user.login}</Text>
</View>
<View style={styles.infoRow}>
<Icon name="drive-file-rename-outline" size={20} color="#666" />
<Text style={styles.infoLabel}>Имя:</Text>
<Text style={styles.infoValue}>{user.name || 'Не указано'}</Text>
</View>
{user.token && (
<View style={styles.infoRow}>
<Icon name="vpn-key" size={20} color="#4caf50" />
<Text style={styles.infoLabel}>Токен:</Text>
<Text style={styles.tokenValue} numberOfLines={1}>
{user.token.substring(0, 20)}...
</Text>
</View>
)}
</View>
{/* Действия */}
<View style={styles.actions}>
<TouchableOpacity style={styles.actionButton} onPress={handleShare}>
<Icon name="share" size={20} color="#3f51b5" />
<Text style={styles.actionText}>Поделиться</Text>
</TouchableOpacity>
{isOwnProfile && (
<TouchableOpacity
style={[styles.actionButton, styles.editButton]}
onPress={() => navigation.navigate('Edit', { userId: user.id })}
>
<Icon name="edit" size={20} color="#fff" />
<Text style={[styles.actionText, styles.editButtonText]}>
Редактировать
</Text>
</TouchableOpacity>
)}
</View>
{/* Дополнительная информация для своего профиля */}
{isOwnProfile && token && (
<View style={styles.tokenInfoCard}>
<Text style={styles.tokenInfoTitle}>Ваш JWT токен</Text>
<Text style={styles.tokenFullValue} selectable>
{token}
</Text>
</View>
)}
</View>
</ScrollView>
);
};
const ProfileScreen: React.FC<ProfileScreenProps> = (props) => {
return (
<AuthGuard navigation={props.navigation} redirectTo="Auth">
<ProfileContent {...props} />
</AuthGuard>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
content: {
padding: 16,
},
header: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 24,
alignItems: 'center',
marginBottom: 16,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3f51b5',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
position: 'relative',
},
ownAvatar: {
backgroundColor: '#f50057',
},
avatarText: {
color: '#fff',
fontSize: 40,
fontWeight: 'bold',
},
ownBadge: {
position: 'absolute',
bottom: 5,
right: 5,
backgroundColor: '#ffc107',
borderRadius: 15,
width: 30,
height: 30,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: '#fff',
},
name: {
fontSize: 24,
fontWeight: 'bold',
color: '#333',
marginBottom: 4,
},
login: {
fontSize: 16,
color: '#666',
marginBottom: 12,
},
tokenContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#e8f5e8',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
gap: 6,
},
tokenText: {
fontSize: 12,
color: '#4caf50',
},
infoCard: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 16,
elevation: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
infoTitle: {
fontSize: 18,
fontWeight: '600',
color: '#333',
marginBottom: 16,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
gap: 12,
},
infoLabel: {
fontSize: 14,
color: '#666',
width: 60,
},
infoValue: {
flex: 1,
fontSize: 14,
color: '#333',
fontWeight: '500',
},
tokenValue: {
flex: 1,
fontSize: 12,
color: '#4caf50',
fontFamily: 'monospace',
},
actions: {
flexDirection: 'row',
gap: 12,
marginBottom: 16,
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
borderRadius: 8,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: '#3f51b5',
},
editButton: {
backgroundColor: '#3f51b5',
borderColor: '#3f51b5',
},
actionText: {
fontSize: 14,
color: '#3f51b5',
fontWeight: '500',
},
editButtonText: {
color: '#fff',
},
tokenInfoCard: {
backgroundColor: '#e8f5e8',
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
tokenInfoTitle: {
fontSize: 14,
fontWeight: '600',
color: '#4caf50',
marginBottom: 8,
},
tokenFullValue: {
fontSize: 12,
color: '#333',
fontFamily: 'monospace',
backgroundColor: '#fff',
padding: 12,
borderRadius: 8,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 16,
color: '#f44336',
textAlign: 'center',
marginTop: 16,
marginBottom: 16,
},
retryButton: {
backgroundColor: '#3f51b5',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
});
export default ProfileScreen;src/App.tsx:
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { StatusBar } from 'react-native';
import { AuthProvider } from './contexts/AuthContext';
import { LoadingOverlay } from './components/LoadingOverlay';
import HomeScreen from './screens/HomeScreen';
import UsersScreen from './screens/UsersScreen';
import ProfileScreen from './screens/ProfileScreen';
import AuthScreen from './screens/AuthScreen';
import SignScreen from './screens/SignScreen';
import LoadingDemoScreen from './screens/LoadingDemoScreen';
import Header from './components/Header';
export type RootStackParamList = {
Home: undefined;
Users: undefined;
Profile: { userId: number };
Auth: { registered?: boolean; returnUrl?: string } | undefined;
Sign: undefined;
LoadingDemo: undefined;
};
const Stack = createNativeStackNavigator<RootStackParamList>();
const App: React.FC = () => {
return (
<SafeAreaProvider>
<StatusBar backgroundColor="#3f51b5" barStyle="light-content" />
<AuthProvider>
<NavigationContainer>
<LoadingOverlay text="Загрузка..." showProgress />
<Stack.Navigator
screenOptions={({ navigation, route }) => ({
header: () => (
<Header
navigation={navigation}
showBack={route.name !== 'Home'}
title={route.name === 'Home' ? 'Главная' :
route.name === 'Users' ? 'Пользователи' :
route.name === 'Profile' ? 'Профиль' :
route.name === 'Auth' ? 'Вход' :
route.name === 'Sign' ? 'Регистрация' :
'Демо лоадера'}
/>
),
})}
>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Users" component={UsersScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen name="Auth" component={AuthScreen} />
<Stack.Screen name="Sign" component={SignScreen} />
<Stack.Screen name="LoadingDemo" component={LoadingDemoScreen} />
</Stack.Navigator>
</NavigationContainer>
</AuthProvider>
</SafeAreaProvider>
);
};
export default App;-
Тест AuthGuard:
- Выйдите из системы
- Попробуйте перейти на экран пользователей через навигацию
- Должно появиться уведомление о необходимости авторизации
- Должен произойти редирект на экран входа
-
Тест редиректа после авторизации:
- Попробуйте перейти на защищенный экран (например, Users)
- Вы будете перенаправлены на Auth с returnUrl
- После успешной авторизации вернетесь на запрошенный экран
-
Тест PreventUnsavedChanges:
- Перейдите на экран регистрации
- Заполните любое поле
- Нажмите аппаратную кнопку "Назад" на Android
- Должно появиться диалоговое окно с подтверждением
-
Тест навигации с несохраненными изменениями:
- На экране регистрации заполните поля
- Попробуйте перейти по ссылке "Уже есть аккаунт?"
- Должен появиться диалог подтверждения
| Angular | React Native |
|---|---|
CanActivateFn |
AuthGuard компонент-обертка |
CanDeactivateFn |
PreventUnsavedChanges компонент |
inject(Router) |
navigation из пропсов |
snackBar.open() |
Alert.alert() |
queryParams |
route.params |
confirm() |
Кастомный Modal или Alert
|
# Проверка статуса
git status
# Создание ветки sprint6
git checkout -b sprint6
# Добавление всех изменений
git add -A
# Создание коммита
git commit -m "Выполнен sprint6: Защитники маршрутов"
# Переключение на master
git checkout master
# Ребейз sprint6 в master
git rebase sprint6Эта реализация полностью воспроизводит функциональность Angular guards на React Native с:
- Защитой маршрутов от неавторизованных пользователей
- Сохранением returnUrl для редиректа после авторизации
- Защитой от потери несохраненных данных в форме
- Диалогом подтверждения при попытке ухода со страницы
- Обработкой аппаратной кнопки "Назад" на Android