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