Skip to content

React Native 6. Guard

IOF edited this page Mar 8, 2026 · 1 revision

В этом спринте мы реализуем защиту маршрутов (Auth Guard) и защиту от несохраненных изменений (CanDeactivate) в React Native приложении.

1. Установка дополнительных зависимостей

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 ..

2. Создание Auth Guard (защита авторизованных пользователей)

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

3. Создание Guard для предотвращения несохраненных изменений

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

4. Создание хука для защиты от несохраненных изменений

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
    };
};

5. Создание экрана регистрации с защитой от несохраненных изменений

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;

6. Обновление экрана авторизации с обработкой returnUrl

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;

7. Обновление экрана Users с защитой AuthGuard

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;

8. Обновление экрана Profile с защитой AuthGuard

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;

9. Обновление навигации для передачи параметров

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;

Тестирование функциональности

  1. Тест AuthGuard:

    • Выйдите из системы
    • Попробуйте перейти на экран пользователей через навигацию
    • Должно появиться уведомление о необходимости авторизации
    • Должен произойти редирект на экран входа
  2. Тест редиректа после авторизации:

    • Попробуйте перейти на защищенный экран (например, Users)
    • Вы будете перенаправлены на Auth с returnUrl
    • После успешной авторизации вернетесь на запрошенный экран
  3. Тест PreventUnsavedChanges:

    • Перейдите на экран регистрации
    • Заполните любое поле
    • Нажмите аппаратную кнопку "Назад" на Android
    • Должно появиться диалоговое окно с подтверждением
  4. Тест навигации с несохраненными изменениями:

    • На экране регистрации заполните поля
    • Попробуйте перейти по ссылке "Уже есть аккаунт?"
    • Должен появиться диалог подтверждения

Сравнение Angular vs React Native подходов для Guards

Angular React Native
CanActivateFn AuthGuard компонент-обертка
CanDeactivateFn PreventUnsavedChanges компонент
inject(Router) navigation из пропсов
snackBar.open() Alert.alert()
queryParams route.params
confirm() Кастомный Modal или Alert

Команды Git

# Проверка статуса
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

Clone this wiki locally