Урок 11

Защищённые экраны и авторизация

Паттерн: экраны логина и экраны приложения разделены. Пользователь видит одни или другие в зависимости от состояния.

Структура

RootStack
├── Auth (Stack)
│   ├── Login
│   └── Register
└── App (Stack)
    ├── Tabs
    ├── Details
    └── Settings

Реализация

function RootNavigator() {
    const { user, isLoading } = useAuth();

    if (isLoading) return <SplashScreen />;

    return (
        <Stack.Navigator screenOptions={{ headerShown: false }}>
            {user ? (
                <Stack.Group>
                    <Stack.Screen name="App" component={AppNavigator} />
                    <Stack.Screen name="Modal" component={ModalScreen} options={{ presentation: 'modal' }} />
                </Stack.Group>
            ) : (
                <Stack.Group>
                    <Stack.Screen name="Login" component={LoginScreen} />
                    <Stack.Screen name="Register" component={RegisterScreen} />
                </Stack.Group>
            )}
        </Stack.Navigator>
    );
}

Когда user меняется — навигатор автоматически переключается.

Auth Context

type AuthContextValue = {
    user: User | null;
    isLoading: boolean;
    login: (email: string, password: string) => Promise<void>;
    logout: () => Promise<void>;
};

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
    const [user, setUser] = useState<User | null>(null);
    const [isLoading, setIsLoading] = useState(true);

    useEffect(() => {
        async function load() {
            const token = await SecureStore.getItemAsync('token');
            if (token) {
                const user = await fetchUser(token);
                setUser(user);
            }
            setIsLoading(false);
        }
        load();
    }, []);

    async function login(email: string, password: string) {
        const { user, token } = await api.login(email, password);
        await SecureStore.setItemAsync('token', token);
        setUser(user);
    }

    async function logout() {
        await SecureStore.deleteItemAsync('token');
        setUser(null);
    }

    return (
        <AuthContext.Provider value={{ user, isLoading, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
}

Экран логина

function LoginScreen() {
    const { login } = useAuth();
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState('');
    const [loading, setLoading] = useState(false);

    async function handleSubmit() {
        setLoading(true);
        setError('');
        try {
            await login(email, password);
        } catch (e) {
            setError((e as Error).message);
        } finally {
            setLoading(false);
        }
    }

    return (
        <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
            <View style={styles.container}>
                <Text style={styles.title}>Вход</Text>
                <TextInput value={email} onChangeText={setEmail} placeholder="Email" keyboardType="email-address" autoCapitalize="none" />
                <TextInput value={password} onChangeText={setPassword} placeholder="Пароль" secureTextEntry />
                {error ? <Text style={styles.error}>{error}</Text> : null}
                <Button label="Войти" onPress={handleSubmit} loading={loading} />
            </View>
        </KeyboardAvoidingView>
    );
}

SecureStore

Для токенов:

npx expo install expo-secure-store
import * as SecureStore from 'expo-secure-store';

await SecureStore.setItemAsync('token', token);
const token = await SecureStore.getItemAsync('token');
await SecureStore.deleteItemAsync('token');

Токены в Keychain (iOS) / Keystore (Android). Не в AsyncStorage.

Logout

function SettingsScreen() {
    const { logout } = useAuth();

    return (
        <Button
            label="Выйти"
            onPress={() => {
                Alert.alert('Выйти?', 'Вы уверены?', [
                    { text: 'Отмена', style: 'cancel' },
                    { text: 'Выйти', style: 'destructive', onPress: logout },
                ]);
            }}
        />
    );
}

Навигатор сам переключится на экраны авторизации.

Обновление токена

Если используешь short-lived токены:

async function apiCall(url: string, options?: RequestInit) {
    const token = await SecureStore.getItemAsync('token');

    const res = await fetch(url, {
        ...options,
        headers: { ...options?.headers, Authorization: `Bearer ${token}` },
    });

    if (res.status === 401) {
        const refreshed = await refreshToken();
        if (!refreshed) {
            await logout();
            return res;
        }
        return apiCall(url, options);
    }

    return res;
}

Защита по роли

function AdminScreen() {
    const { user } = useAuth();

    if (user?.role !== 'admin') {
        return <NoAccessScreen />;
    }

    return <AdminPanel />;
}

Но это только UX. Настоящая защита — на сервере.

Итоги

  • Два стека: Auth и App
  • user определяет, какой показать
  • Auth Context с login и logout
  • SecureStore для токенов
  • Серверная защита обязательна