eletrotupi / tcc/ commit / 1144332

frontend/auth: read the account state from one status

The gate lived in two places and they disagreed on what signed in meant,
so a back gesture from the activation screen could drop an unactivated
account into the app. One derived status now feeds both the redirect and
the tabs guard, and the auth stack is no longer dismissible
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago 11443325051f8ebf8d5bdcba4d184e4c95d1f887 Browse files
Parents e1b36f7
Changed files
8
Additions
+156
Deletions
-120
frontend/app/(tabs)/_layout.tsx
1 1
import { Tabs } from "expo-router/js-tabs";
2 2
import { Redirect } from "expo-router";
3 3
import { View, ActivityIndicator } from "react-native";
4 4

5 5
import { HapticTab } from "@/components/misc/haptic-tab";
6 6
import { IconSymbol } from "@/components/ui/icon-symbol";
7 7
import { Colors } from "@/constants/theme";
8 8
import { useAuth } from "@/context/AuthContext";
9 +
import { routeForStatus } from "@/context/authRouting";
9 10

10 11
export default function TabLayout() {
11 -
  const { isAuthenticated, isLoading } = useAuth();
12 +
  const { status, user } = useAuth();
13 +
  const href = routeForStatus(status, user);
12 14

13 -
  if (isLoading) {
15 +
  if (!href) {
14 16
    return (
15 17
      <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
16 18
        <ActivityIndicator size="large" />
17 19
      </View>
18 20
    );
19 21
  }
20 22

21 -
  if (!isAuthenticated) {
22 -
    return <Redirect href="/auth/login" />;
23 +
  // `active` with onboarding seen is the only state that may render the tabs.
24 +
  if (status !== "active" || !user?.onboardingSeenAt) {
25 +
    return <Redirect href={href} />;
23 26
  }
24 27

25 28
  return (
26 29
    <Tabs
27 30
      screenOptions={{
87 90
        }}
88 91
      />
89 92
    </Tabs>
90 93
  );
91 94
}
frontend/app/_layout.tsx
1 1
import {
2 2
  DarkTheme,
3 3
  DefaultTheme,
4 4
  Stack,
5 5
  ThemeProvider,
10 10
import { useNotificationObserver } from "@/hooks/useNotificationObserver";
11 11
import { AuthProvider } from "@/context/AuthContext";
12 12
import { ToastProvider } from "@/context/ToastContext";
13 13

14 14
export const unstable_settings = {
15 -
  initialRouteName: "(tabs)",
15 +
  initialRouteName: "index",
16 16
};
17 17

18 18
const AppBootstrap = () => {
19 19
  useNotificationObserver();
20 20
  return null;
35 35
            <Stack.Screen
36 36
              name="auth"
37 37
              options={{
38 38
                headerShown: false,
39 39
                presentation: "modal",
40 +
                gestureEnabled: false,
40 41
              }}
42 +
            />
43 +

44 +
            <Stack.Screen
45 +
              name="onboarding"
46 +
              options={{ headerShown: false, gestureEnabled: false }}
47 +
            />
48 +

49 +
            <Stack.Screen
50 +
              name="terms"
51 +
              options={{ title: "Termos e Condições" }}
41 52
            />
42 53

43 54
            <Stack.Screen
44 55
              name="profile"
45 56
              options={{
126 137
        </ToastProvider>
127 138
      </ThemeProvider>
128 139
    </QueryClientProvider>
129 140
  );
130 141
}
frontend/app/index.tsx modified+6 -13
frontend/app/index.tsx
1 -
import { Redirect, type Href } from "expo-router";
1 +
import { Redirect } from "expo-router";
2 2
import { useAuth } from "@/context/AuthContext";
3 +
import { routeForStatus } from "@/context/authRouting";
3 4
import { ActivityIndicator, View } from "react-native";
4 5

5 6
export default function Index() {
6 -
  const { isAuthenticated, isLoading, user } = useAuth();
7 -
  let url: Href = "/auth/login";
7 +
  const { status, user } = useAuth();
8 +
  const href = routeForStatus(status, user);
8 9

9 -
  if (isLoading) {
10 +
  if (!href) {
10 11
    return (
11 12
      <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
12 13
        <ActivityIndicator size="large" />
13 14
      </View>
14 15
    );
15 16
  }
16 17

17 -
  if (isAuthenticated) {
18 -
    if (user?.active) {
19 -
      url = "/(tabs)/new";
20 -
    } else {
21 -
      url = "/auth/activate";
22 -
    }
23 -
  }
24 -

25 -
  return <Redirect href={url} />;
18 +
  return <Redirect href={href} />;
26 19
}
frontend/app/notifications.tsx
1 1
import {
2 2
  StyleSheet,
3 3
  ScrollView,
4 4
  KeyboardAvoidingView,
5 5
} from "react-native";
6 6
import { Text } from "@/components/ui/Text";
7 7

8 8
import { useState, useEffect } from "react";
9 9
import * as Notifications from "expo-notifications";
10 -
import { useAuth } from "@/context/AuthContext";
10 +
import { registerForPushNotifications } from "@/hooks/useRegisterPushToken";
11 11
import { useLocalSearchParams } from "expo-router";
12 12
import { StatusBar } from "expo-status-bar";
13 13
import { SafeAreaView } from "react-native-safe-area-context";
14 14
import { Button } from "@/components/ui/Button";
15 15
import { Colors, Spacing } from "@/constants/theme";
26 26
  title?: string;
27 27
  body?: string;
28 28
};
29 29

30 30
export default function NotificationsPage() {
31 -
  const { user } = useAuth();
32 31
  const params = useLocalSearchParams<NotificationParams>();
33 -
  const expoPushToken = user?.pushToken;
34 32
  const [liveNotification, setLiveNotification] = useState<ReceivedNotification | null>(null);
35 33

36 34
  useEffect(() => {
37 35
    const sub = Notifications.addNotificationReceivedListener((n) => {
38 36
      setLiveNotification({
44 42
  }, []);
45 43

46 44
  const displayed = liveNotification ?? (params.title || params.body ? { title: params.title, body: params.body } : null);
47 45

48 46
  async function sendPushNotification() {
47 +
    const expoPushToken = await registerForPushNotifications();
48 +

49 49
    if (expoPushToken) {
50 50
      const message = {
51 51
        to: expoPushToken,
52 52
        sound: "default",
53 53
        title: "Oi meu querido",
114 114
  header: {
115 115
    alignItems: "flex-start",
116 116
    marginBottom: 40,
117 117
  },
118 118
});
frontend/context/AuthContext.tsx
1 -
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
1 +
import React, {
2 +
  createContext,
3 +
  useCallback,
4 +
  useContext,
5 +
  useEffect,
6 +
  useMemo,
7 +
  useState,
8 +
} from "react";
2 9
import { apiClient, User } from "@/lib/api";
3 10
import { queryClient, storage } from "@/lib/queryClient";
11 +
import { AuthStatus, deriveStatus } from "@/context/authRouting";
4 12

5 13
interface AuthProviderProps {
6 14
  children: React.ReactNode;
7 15
}
8 16

17 +
interface SignUpInput {
18 +
  firstName: string;
19 +
  lastName?: string;
20 +
  email: string;
21 +
  password: string;
22 +
  acceptedTerms: boolean;
23 +
}
24 +

9 25
interface AuthContextType {
10 26
  user: User | null;
27 +
  status: AuthStatus;
11 28
  isLoading: boolean;
12 -
  isAuthenticated: boolean;
13 29
  updateAuthUser: (user: User) => void;
14 30
  login: (email: string, password: string) => Promise<{ user: User } | null>;
15 -
  signup: (userData: {
16 -
    firstName: string;
17 -
    lastName?: string;
18 -
    email: string;
19 -
    password: string;
20 -
  }) => Promise<void>;
31 +
  signup: (userData: SignUpInput) => Promise<void>;
21 32
  logout: () => Promise<void>;
22 -
  forgotPassword: (
23 -
    email: string,
24 -
  ) => Promise<{ message: string; token?: string }>;
33 +
  forgotPassword: (email: string) => Promise<{ success: boolean }>;
25 34
  resetPassword: (token: string, password: string) => Promise<void>;
26 35
  activate: (code: string) => Promise<void>;
27 36
  requestActivateCode: () => Promise<void>;
28 -
}
29 -

30 -
export function sanitizeUser(data: User): User {
31 -
  return {
32 -
    id: data.id,
33 -
    email: data.email,
34 -
    firstName: data.firstName,
35 -
    lastName: data.lastName,
36 -
    updatedAt: data.updatedAt,
37 -
    avatarKey: data.avatarKey,
38 -
    avatarURL: data.avatarURL,
39 -
    active: data.active,
40 -
    notificationsEnabled: data.notificationsEnabled,
41 -
    dailyReminderTime: data.dailyReminderTime,
42 -
  };
37 +
  completeOnboarding: () => Promise<void>;
43 38
}
44 39

45 40
const AuthContext = createContext<AuthContextType | null>(null);
46 41

47 42
export function useAuth() {
54 49
}
55 50

56 51
export const AuthProvider = ({ children }: AuthProviderProps) => {
57 52
  const [user, setUser] = useState<User | null>(null);
58 53
  const [isLoading, setIsLoading] = useState(true);
59 -
  const isAuthenticated = !!user;
54 +
  const status = deriveStatus(isLoading, user);
60 55

61 -
  useEffect(() => {
62 -
    checkAuthState();
63 -
  }, []);
56 +
  const logout = useCallback(async () => {
57 +
    setIsLoading(true);
58 +
    try {
59 +
      await apiClient.logout();
64 60

65 -
  useEffect(() => {
66 -
    apiClient.registerUnauthorizedHandler(() => {
67 -
      logout();
68 -
    });
61 +
      setUser(null);
62 +
      // Clear both the in-memory cache and the persisted MMKV cache
63 +
      queryClient.clear();
64 +
      storage.clearAll();
65 +
    } finally {
66 +
      setIsLoading(false);
67 +
    }
69 68
  }, []);
70 69

71 -
  const checkAuthState = async () => {
70 +
  const checkAuthState = useCallback(async () => {
72 71
    const doAuthCheck = async () => {
73 72
      const { token } = await apiClient.getStoredAuthData();
74 73
      if (token) {
75 -
        await apiClient.verifyToken();
76 74
        const { user } = await apiClient.getMe();
77 75
        setUser(user);
78 76
      }
79 77
    };
80 78

88 86
      console.error("Auth check failed:", error);
89 87
      await apiClient.logout();
90 88
    } finally {
91 89
      setIsLoading(false);
92 90
    }
93 -
  };
91 +
  }, []);
94 92

95 -
  const login = async (
96 -
    email: string,
97 -
    password: string,
98 -
  ): Promise<{ user: User } | null> => {
93 +
  useEffect(() => {
94 +
    checkAuthState();
95 +
  }, [checkAuthState]);
96 +

97 +
  useEffect(() => {
98 +
    apiClient.registerUnauthorizedHandler(logout);
99 +
  }, [logout]);
100 +

101 +
  const login = useCallback(async (email: string, password: string) => {
99 102
    const response = await apiClient.login(email, password);
100 103

101 104
    if (response.user) {
102 105
      setUser(response.user);
103 106
    }
104 107

105 108
    return response;
106 -
  };
109 +
  }, []);
107 110

108 -
  const signup = async (userData: {
109 -
    firstName: string;
110 -
    lastName?: string;
111 -
    email: string;
112 -
    password: string;
113 -
  }) => {
111 +
  const signup = useCallback(async (userData: SignUpInput) => {
114 112
    const response = await apiClient.signup(userData);
115 -
    setUser(response.user);
116 -
  };
117 -

118 -
  const logout = async () => {
119 -
    setIsLoading(true);
120 -
    try {
121 -
      await apiClient.logout();
122 113

123 -
      setUser(null);
124 -
      // Clear both the in-memory cache and the persisted MMKV cache
125 -
      queryClient.clear();
126 -
      storage.clearAll();
127 -
    } finally {
128 -
      setIsLoading(false);
114 +
    if (response.user) {
115 +
      setUser(response.user);
129 116
    }
130 -
  };
117 +
  }, []);
131 118

132 -
  const forgotPassword = async (email: string) => {
133 -
    return apiClient.forgotPassword(email);
134 -
  };
119 +
  const forgotPassword = useCallback(
120 +
    (email: string) => apiClient.forgotPassword(email),
121 +
    [],
122 +
  );
135 123

136 -
  const resetPassword = async (token: string, password: string) => {
124 +
  const resetPassword = useCallback(async (token: string, password: string) => {
137 125
    await apiClient.resetPassword(token, password);
138 -
  };
126 +
  }, []);
139 127

140 -
  const activate = async (code: string) => {
128 +
  const activate = useCallback(async (code: string) => {
141 129
    const response = await apiClient.activate(code);
142 130
    setUser(response.user);
143 -
  };
131 +
  }, []);
144 132

145 -
  const requestActivateCode = async () => {
146 -
    await apiClient.requestActivateCode();
147 -
  };
133 +
  const requestActivateCode = useCallback(
134 +
    () => apiClient.requestActivateCode(),
135 +
    [],
136 +
  );
148 137

149 -
  const updateAuthUser = (user: User) => {
138 +
  const completeOnboarding = useCallback(async () => {
139 +
    const { user } = await apiClient.completeOnboarding();
150 140
    setUser(user);
151 -
  };
141 +
  }, []);
142 +

143 +
  const updateAuthUser = useCallback((user: User) => {
144 +
    setUser(user);
145 +
  }, []);
152 146

153 147
  const value = useMemo<AuthContextType>(
154 148
    () => ({
155 149
      user,
150 +
      status,
156 151
      isLoading,
157 -
      isAuthenticated,
158 152
      login,
159 153
      signup,
160 154
      logout,
161 155
      forgotPassword,
162 156
      resetPassword,
163 157
      updateAuthUser,
164 158
      activate,
165 159
      requestActivateCode,
160 +
      completeOnboarding,
166 161
    }),
167 -
    // eslint-disable-next-line react-hooks/exhaustive-deps
168 -
    [user, isLoading, isAuthenticated],
162 +
    [
163 +
      user,
164 +
      status,
165 +
      isLoading,
166 +
      login,
167 +
      signup,
168 +
      logout,
169 +
      forgotPassword,
170 +
      resetPassword,
171 +
      updateAuthUser,
172 +
      activate,
173 +
      requestActivateCode,
174 +
      completeOnboarding,
175 +
    ],
169 176
  );
170 177

171 178
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
172 179
};
/dev/null → frontend/context/authRouting.ts
1 +
import { type Href } from 'expo-router';
2 +
import { User } from '@/lib/api';
3 +

4 +
export type AuthStatus =
5 +
  | 'loading'
6 +
  | 'signedOut'
7 +
  | 'awaitingActivation'
8 +
  | 'active';
9 +

10 +
// The single source of truth for "where does this account belong". Everything
11 +
// that routes on auth state reads this instead of re-deriving it from
12 +
// `user.active` and ad-hoc booleans.
13 +
export function deriveStatus(isLoading: boolean, user: User | null): AuthStatus {
14 +
  if (isLoading) return 'loading';
15 +
  if (!user) return 'signedOut';
16 +
  if (!user.active) return 'awaitingActivation';
17 +
  return 'active';
18 +
}
19 +

20 +
// `null` means "still loading — show a spinner, do not navigate".
21 +
export function routeForStatus(
22 +
  status: AuthStatus,
23 +
  user: User | null,
24 +
): Href | null {
25 +
  switch (status) {
26 +
    case 'loading':
27 +
      return null;
28 +
    case 'signedOut':
29 +
      return '/auth/login';
30 +
    case 'awaitingActivation':
31 +
      return '/auth/activate';
32 +
    case 'active':
33 +
      return user?.onboardingSeenAt ? '/(tabs)/new' : '/onboarding';
34 +
  }
35 +
}
frontend/lib/api/client.ts
1 1
import * as SecureStore from "expo-secure-store";
2 2
import Constants from "expo-constants";
3 3

4 4
export class ApiError extends Error {
5 5
  status: number;
13 13
  }
14 14
}
15 15
import {
16 16
  User,
17 17
  AuthResponse,
18 -
  VerifyTokenResponse,
19 18
  SignUpPayload,
20 19
  ResetPasswordRequestResponse,
21 20
  ResetPasswordResponse,
22 21
  UserUpdatePayload,
23 22
  CreateMoodEntryPayload,
202 201
      method: "POST",
203 202
      body: JSON.stringify({ token, newPassword: password }),
204 203
    });
205 204
  }
206 205

207 -
  async verifyToken(): Promise<VerifyTokenResponse> {
208 -
    try {
209 -
      return await this.request("/auth/verify");
210 -
    } catch (error) {
211 -
      // Token is invalid or expired
212 -
      await this.removeToken();
213 -
      throw error;
214 -
    }
215 -
  }
216 -

217 206
  async logout(): Promise<void> {
218 207
    await this.removeToken();
219 208
  }
220 209

221 210
  // User-related
222 211
  async getMe(): Promise<{ user: User }> {
223 212
    return this.request('/users/me');
213 +
  }
214 +

215 +
  async completeOnboarding(): Promise<{ user: User }> {
216 +
    return this.request('/users/me/onboarding/complete', { method: 'POST' });
224 217
  }
225 218

226 219
  async updateUser(user: UserUpdatePayload): Promise<{ user: User }> {
227 220
    return await this.request(`/users/${user.id}`, {
228 221
      method: "PUT",
527 520
    return response.arrayBuffer()
528 521
  }
529 522
}
530 523

531 524
export const apiClient = new ApiClient();
frontend/lib/api/types.ts
1 1
export interface User {
2 2
  id: number;
3 3
  email: string;
4 4
  firstName: string;
5 5
  lastName?: string;
6 6
  updatedAt: Date;
7 7
  avatarKey?: string;
8 8
  avatarURL?: string;
9 9
  active: boolean;
10 -
  pushToken?: string;
11 10
  notificationsEnabled?: boolean;
12 11
  dailyReminderTime?: string;
12 +
  onboardingSeenAt?: Date | null;
13 13
}
14 14

15 15
export interface AvatarResponse {
16 16
  message: string;
17 17
  fileLocation: string;
29 29
export interface AuthResponse {
30 30
  token: string;
31 31
  user: User;
32 32
}
33 33

34 -
export interface VerifyTokenResponse {
35 -
  valid: boolean;
36 -
  userId?: number;
37 -
  email?: string;
38 -
  user: Pick<User, "firstName" | "lastName">;
39 -
}
40 -

41 34
export interface SignUpPayload {
42 35
  firstName: string;
43 36
  lastName?: string;
44 37
  email: string;
45 38
  password: string;
39 +
  acceptedTerms: boolean;
46 40
}
47 41

48 42
export interface ResetPasswordRequestResponse {
49 -
  message: string;
50 -
  token?: string;
43 +
  success: boolean;
51 44
}
52 45

53 46
export interface ResetPasswordResponse {
54 -
  message: string;
47 +
  success: boolean;
55 48
}
56 49

57 50
export interface ActivateResponse {
58 51
  user: User;
59 52
}
60 53

61 54
// User
62 55
export interface UserUpdatePayload extends Partial<User> {
63 56
  id: number;
64 57
  password?: string;
58 +
  pushToken?: string;
65 59
}
66 60

67 61
export interface UserUpdateResponse {
68 62
  user: User;
69 63
}
394 388
  moodId?: number;
395 389
}
396 390
export interface MedicineRegimenResponse { regimen: MedicineRegimen; }
397 391

398 392
export interface LinkMoodPayload { moodId: number; perceivedImpact: number; }