feat: implement Google OAuth support for Android and iOS
- Added support for Google OAuth with separate client IDs for Android and iOS. - Updated `verify_google_id_token` to validate `aud` against both client IDs and check `email_verified`. - Modified `google_oauth_handler` to accept and process the new client IDs. - Enhanced security by enforcing explicit JWT algorithm validation. - Updated mobile app to handle Google OAuth flow using `expo-auth-session`. - Fixed API request to send `id_token` in snake_case as expected by the backend. - Added necessary environment variables for Google client IDs in mobile app. - Implemented intent filter for Google OAuth redirect in AndroidManifest.xml.
This commit is contained in:
+134
-4
@@ -33,7 +33,8 @@ O app é **offline-first**: todos os dados são persistidos localmente em **SQLi
|
||||
| Câmera / Scanner | `expo-camera` ~17 | ML Kit barcode scan; requer development build |
|
||||
| NFC | `react-native-nfc-manager` | Leitura e gravação NDEF (NTAG215); requer build nativo |
|
||||
| File system | `expo-file-system/legacy` | Salvar arquivos no cache; usar import `/legacy` |
|
||||
| Compartilhamento | `expo-sharing` | Sheet nativo de compartilhamento de arquivos |
|
||||
| Compartilhamento | `expo-sharing` | Sheet nativo de compartilhamento de arquivos (iOS) |
|
||||
| Abrir arquivo | `expo-intent-launcher` | ACTION_VIEW no Android — abre apps de impressão (Niimbot) |
|
||||
|
||||
---
|
||||
|
||||
@@ -370,11 +371,139 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
|
||||
- [ ] NFC suporte iOS (requer entitlement `com.apple.developer.nfc.readwrite`)
|
||||
- [ ] Expo Notifications para alertas de estoque baixo
|
||||
- [x] Deep link `verify-email?token=` com handler e integração ao backend
|
||||
- [ ] Google OAuth com `expo-auth-session`
|
||||
- [x] Google OAuth com `expo-auth-session` (Android + iOS, `Google.useAuthRequest` + `promptAsync`)
|
||||
- [ ] Testes de integração com Jest + Testing Library
|
||||
|
||||
---
|
||||
|
||||
## Mudanças Recentes (19/03/2026) — segunda entrada
|
||||
|
||||
### ✅ Google OAuth — implementação completa
|
||||
|
||||
**Arquivos modificados**: `app/(auth)/login.tsx`, `src/infrastructure/container.ts`, `src/adapters/remote/ApiAuthRepository.ts`
|
||||
|
||||
**Pacotes instalados**: `expo-auth-session`, `expo-web-browser`
|
||||
|
||||
#### Fluxo implementado
|
||||
|
||||
```
|
||||
Usuário toca "Entrar com Google"
|
||||
→ promptAsync() abre browser via expo-web-browser
|
||||
→ Google OAuth consent screen
|
||||
→ redirect de volta ao app (scheme meowspool://)
|
||||
→ useEffect detecta response.type === 'success'
|
||||
→ authentication.idToken enviado para GoogleLoginUseCase
|
||||
→ POST /auth/oauth/google { id_token }
|
||||
→ backend valida aud, email_verified, cria/encontra usuário
|
||||
→ retorna JWT pair → setSession → navega para home
|
||||
```
|
||||
|
||||
#### Detalhes técnicos
|
||||
|
||||
**`login.tsx`** — hook `Google.useAuthRequest` + `useEffect` no `response`:
|
||||
|
||||
```tsx
|
||||
WebBrowser.maybeCompleteAuthSession(); // fora do componente
|
||||
|
||||
const [_request, response, promptAsync] = Google.useAuthRequest({
|
||||
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID,
|
||||
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (response?.type === 'success') {
|
||||
const idToken = response.authentication?.idToken;
|
||||
googleLoginUseCase.execute({ idToken })
|
||||
.then(session => setSession(session))
|
||||
.then(() => router.replace('/(app)/(tabs)/home'))
|
||||
.finally(() => setIsGoogleLoading(false));
|
||||
}
|
||||
}, [response]);
|
||||
```
|
||||
|
||||
- `onGoogleLogin` agora é síncrono e apenas chama `promptAsync()`
|
||||
- Tratamento dos três estados: `success`, `error`, `dismiss`
|
||||
|
||||
**`ApiAuthRepository.ts`** — bug corrigido: o campo era enviado como `idToken` (camelCase) mas o backend espera `id_token` (snake_case):
|
||||
|
||||
```ts
|
||||
// Antes (quebrado):
|
||||
await httpClient.post('/auth/oauth/google', input); // enviava { idToken }
|
||||
|
||||
// Depois (correto):
|
||||
await httpClient.post('/auth/oauth/google', { id_token: input.idToken });
|
||||
```
|
||||
|
||||
**`container.ts`** — adicionado export de `googleLoginUseCase`:
|
||||
|
||||
```ts
|
||||
import { GoogleLoginUseCase } from '@application/auth/AuthUseCases';
|
||||
export const googleLoginUseCase = new GoogleLoginUseCase(authRepository);
|
||||
```
|
||||
|
||||
#### Variáveis de ambiente necessárias (`mobile/.env`)
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID=724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v.apps.googleusercontent.com
|
||||
EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS=724520558909-v3kubsvmf3vda7fep8qaabenmdap53hs.apps.googleusercontent.com
|
||||
```
|
||||
|
||||
#### Configuração no Google Cloud Console (obrigatória)
|
||||
|
||||
O redirect URI do `expo-auth-session` usa o scheme do app. Adicionar nos OAuth 2.0 Clients:
|
||||
|
||||
- **Android** (client `GOOGLE_CLIENT_ID_ANDROID`): SHA-1 fingerprint da keystore + package name `com.meowspool.app`
|
||||
- **iOS** (client `GOOGLE_CLIENT_ID_IOS`): bundle ID `com.meowspool.app`
|
||||
|
||||
> **Nota**: o `expo-auth-session` com `Google.useAuthRequest` funciona em development build (não no Expo Go). Requer `mise exec -- npx expo run:android` após instalar os pacotes.
|
||||
|
||||
---
|
||||
|
||||
## Mudanças Recentes (19/03/2026)
|
||||
|
||||
### ✅ Hardening de segurança — bypass do interceptor de refresh
|
||||
|
||||
**Arquivo modificado**: `src/store/authStore.ts`
|
||||
|
||||
**Problema**: `loadStoredSession` usava `axios.get` diretamente com o access token lido do SecureStore, bypassando o interceptor de refresh do `httpClient`. Consequência: se o access token expirava, a sessão era destruída mesmo quando o refresh token ainda era válido, forçando login desnecessário.
|
||||
|
||||
**Correção**:
|
||||
|
||||
```ts
|
||||
// Antes (bypass do interceptor):
|
||||
const { data } = await axios.get(`${API_BASE_URL}/users/me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
// Depois (usa httpClient → interceptor de refresh ativo):
|
||||
const { data } = await httpClient.get('/users/me');
|
||||
// Lê tokens após possível refresh (interceptor pode ter renovado o SecureStore)
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN),
|
||||
SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN),
|
||||
]);
|
||||
```
|
||||
|
||||
**Melhorias adicionais no `catch`**:
|
||||
|
||||
```ts
|
||||
const isAuthError =
|
||||
axios.isAxiosError(err) &&
|
||||
(err.response?.status === 401 || err.response?.status === 403);
|
||||
if (isAuthError || !axios.isAxiosError(err)) {
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
|
||||
}
|
||||
set({ isLoading: false });
|
||||
```
|
||||
|
||||
- Erros de rede (sem conexão) não forçam logout — tokens são preservados para quando a conectividade voltar
|
||||
- Somente erros 401/403 limpam a sessão
|
||||
- Import `API_BASE_URL` removido (não mais necessário)
|
||||
- Import `httpClient` adicionado de `@adapters/remote/httpClient`
|
||||
|
||||
---
|
||||
|
||||
## Mudanças Recentes (14/03/2026)
|
||||
|
||||
### ✅ Reenvio de e-mail de verificação
|
||||
@@ -606,8 +735,9 @@ mise exec -- npx expo run:android
|
||||
|
||||
- **Pacotes instalados**: `expo-file-system`, `expo-sharing`, `react-native-worklets`
|
||||
- **Atenção**: importar `expo-file-system` como `expo-file-system/legacy` — a API padrão do SDK 54 não exporta `EncodingType` nem `writeAsStringAsync` diretamente
|
||||
- **Fluxo PDF**: `GET /label.pdf` → resposta como `arraybuffer` → `btoa` para Base64 → salvar com `FileSystem.writeAsStringAsync` (encoding Base64) → `Sharing.shareAsync`
|
||||
- **Fluxo SVG**: `GET /label.svg` → resposta como texto → salvar com encoding `'utf8'` → `Sharing.shareAsync`
|
||||
- **Fluxo PDF**: `GET /label.pdf` → resposta como `arraybuffer` → `btoa` para Base64 → salvar com `FileSystem.writeAsStringAsync` (encoding Base64) → Android: `IntentLauncher.startActivityAsync('android.intent.action.VIEW', ...)` com `content://` URI via `FileSystem.getContentUriAsync` | iOS: `Sharing.shareAsync`
|
||||
- **Fluxo SVG**: `GET /label.svg` → resposta como texto → salvar com encoding `'utf8'` → mesmo padrão Android/iOS acima
|
||||
- **Por que ACTION_VIEW no Android**: `expo-sharing` usa `ACTION_SEND`, que não é declarado pelo Niimbot (e outros apps de impressão). Esses apps declaram apenas `ACTION_VIEW`. Usar `IntentLauncher` com `ACTION_VIEW` + `content://` URI mostra o mesmo seletor que o Gmail usa ao abrir um PDF.
|
||||
- **Seletor de formato**: chips PDF/SVG no footer; padrão é PDF
|
||||
- **Campos selecionáveis**: `enabledContent` (Set) é convertido para string CSV e enviado como `fields` query param — o backend respeita a seleção
|
||||
|
||||
|
||||
+6
-2
@@ -11,7 +11,9 @@
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#1E1B18"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.meowspool.app"
|
||||
@@ -36,7 +38,9 @@
|
||||
"cameraPermission": "O MeowSpool precisa da câmera para escanear QR Codes de filamentos."
|
||||
}
|
||||
],
|
||||
"react-native-nfc-manager"
|
||||
"react-native-nfc-manager",
|
||||
"expo-web-browser",
|
||||
"./plugins/withGoogleOAuth"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, TouchableOpacity, StyleSheet, Alert,
|
||||
View, Text, TouchableOpacity, StyleSheet, Alert, Platform,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import { useFilamentStore } from '@store/filamentStore';
|
||||
import { usePresetStore } from '@store/presetStore';
|
||||
import { calcFilamentPercentage } from '@domain/Filament';
|
||||
@@ -111,7 +112,14 @@ export default function LabelScreen(): React.ReactElement {
|
||||
await FileSystem.writeAsStringAsync(fileUri, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
if (Platform.OS === 'android') {
|
||||
const contentUri = await FileSystem.getContentUriAsync(fileUri);
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
data: contentUri,
|
||||
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
||||
type: 'application/pdf',
|
||||
});
|
||||
} else if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(fileUri, { mimeType: 'application/pdf', UTI: 'com.adobe.pdf' });
|
||||
} else {
|
||||
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
|
||||
@@ -123,7 +131,14 @@ export default function LabelScreen(): React.ReactElement {
|
||||
);
|
||||
const fileUri = `${FileSystem.cacheDirectory}meowspool-label-${id}.svg`;
|
||||
await FileSystem.writeAsStringAsync(fileUri, response.data, { encoding: 'utf8' });
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
if (Platform.OS === 'android') {
|
||||
const contentUri = await FileSystem.getContentUriAsync(fileUri);
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
data: contentUri,
|
||||
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
||||
type: 'image/svg+xml',
|
||||
});
|
||||
} else if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(fileUri, { mimeType: 'image/svg+xml', UTI: 'public.svg-image' });
|
||||
} else {
|
||||
Alert.alert('Compartilhamento indisponível', 'Este dispositivo não suporta compartilhamento de arquivos.');
|
||||
|
||||
+40
-11
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import axios from 'axios';
|
||||
import { Link, useRouter } from 'expo-router';
|
||||
@@ -6,14 +6,19 @@ import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Google from 'expo-auth-session/providers/google';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { Screen } from '@presentation/components/layout/Screen';
|
||||
import { Input } from '@presentation/components/ui/Input';
|
||||
import { Button } from '@presentation/components/ui/Button';
|
||||
import { colors, typography, spacing } from '@shared/theme';
|
||||
import { loginUseCase } from '@infrastructure/container';
|
||||
import { loginUseCase, googleLoginUseCase } from '@infrastructure/container';
|
||||
import { useAuthStore } from '@store/authStore';
|
||||
import { CatIcon } from '@presentation/components/icons/CatIcon';
|
||||
|
||||
// Necessário para fechar o browser após redirect OAuth no Android/iOS
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('E-mail inválido'),
|
||||
password: z.string().min(1, 'Senha obrigatória'),
|
||||
@@ -31,6 +36,36 @@ export default function LoginScreen(): React.ReactElement {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
// Hook do expo-auth-session para Google OAuth.
|
||||
// Usa os client IDs de Android e iOS configurados via variáveis de ambiente.
|
||||
const [_request, response, promptAsync] = Google.useAuthRequest({
|
||||
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_ANDROID,
|
||||
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID_IOS,
|
||||
});
|
||||
|
||||
// Reage ao resultado do fluxo OAuth assim que o browser fecha
|
||||
useEffect(() => {
|
||||
if (response?.type === 'success') {
|
||||
const idToken = response.authentication?.idToken;
|
||||
if (!idToken) {
|
||||
Alert.alert('Erro', 'Não foi possível obter o token do Google.');
|
||||
setIsGoogleLoading(false);
|
||||
return;
|
||||
}
|
||||
googleLoginUseCase
|
||||
.execute({ idToken })
|
||||
.then((session) => setSession(session))
|
||||
.then(() => router.replace('/(app)/(tabs)/home'))
|
||||
.catch(() => Alert.alert('Erro', 'Não foi possível entrar com o Google.'))
|
||||
.finally(() => setIsGoogleLoading(false));
|
||||
} else if (response?.type === 'error') {
|
||||
Alert.alert('Erro', 'Autenticação com Google cancelada ou falhou.');
|
||||
setIsGoogleLoading(false);
|
||||
} else if (response?.type === 'dismiss') {
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
}, [response]);
|
||||
|
||||
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: '', password: '' },
|
||||
@@ -57,16 +92,10 @@ export default function LoginScreen(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function onGoogleLogin(): Promise<void> {
|
||||
function onGoogleLogin(): void {
|
||||
setIsGoogleLoading(true);
|
||||
try {
|
||||
// TODO: Google Sign-In + GoogleLoginUseCase
|
||||
router.replace('/(app)/(tabs)/home');
|
||||
} catch {
|
||||
Alert.alert('Erro', 'Não foi possível entrar com o Google.');
|
||||
} finally {
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
// promptAsync abre o browser; o resultado chega via useEffect no `response`
|
||||
promptAsync();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Generated
+62
@@ -15,10 +15,13 @@
|
||||
"@react-navigation/native-stack": "^7.3.16",
|
||||
"axios": "^1.7.7",
|
||||
"expo": "~54.0.33",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-intent-launcher": "^55.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
@@ -27,6 +30,7 @@
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
@@ -6024,6 +6028,33 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-auth-session": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz",
|
||||
"integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-constants": "~18.0.11",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-linking": "~8.0.10",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-auth-session/node_modules/expo-application": {
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
||||
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
||||
@@ -6058,6 +6089,18 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-crypto": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
|
||||
"integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
@@ -6082,6 +6125,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-intent-launcher": {
|
||||
"version": "55.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-55.0.8.tgz",
|
||||
"integrity": "sha512-MrgQoHC+2AGMDfwg+X+zF/lp0N2tIa7zVtMmPJksyAvOUpgZXN8B5IZaW8JLGSnFhNCYF3eEzJkQiUzXcgX7CA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-keep-awake": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
||||
@@ -6474,6 +6526,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "15.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
|
||||
"integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo/node_modules/@expo/cli": {
|
||||
"version": "54.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
"@react-navigation/native-stack": "^7.3.16",
|
||||
"axios": "^1.7.7",
|
||||
"expo": "~54.0.33",
|
||||
"expo-auth-session": "~7.0.10",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-intent-launcher": "^55.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
@@ -30,6 +33,7 @@
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Plugin local: injeta o intent filter do Google OAuth no AndroidManifest.xml
|
||||
* durante o `expo prebuild`.
|
||||
*
|
||||
* Sem esse intent filter, o Android não sabe que o MeowSpool deve interceptar
|
||||
* o redirect do Google OAuth (scheme com.googleusercontent.apps.*), fazendo o
|
||||
* Chrome tratar a URL como pesquisa.
|
||||
*
|
||||
* O scheme é o reverse do Android OAuth 2.0 client ID registrado no Google Cloud Console.
|
||||
*/
|
||||
const { withAndroidManifest } = require('@expo/config-plugins');
|
||||
|
||||
const GOOGLE_REVERSE_CLIENT_ID =
|
||||
'com.googleusercontent.apps.724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v';
|
||||
|
||||
module.exports = function withGoogleOAuth(config) {
|
||||
return withAndroidManifest(config, (config) => {
|
||||
const manifest = config.modResults;
|
||||
const activities = manifest.manifest.application?.[0]?.activity ?? [];
|
||||
|
||||
const mainActivity = activities.find(
|
||||
(a) => a.$?.['android:name'] === '.MainActivity',
|
||||
);
|
||||
|
||||
if (!mainActivity) return config;
|
||||
|
||||
// Evita duplicar o intent filter em rebuilds consecutivos
|
||||
const alreadyAdded = (mainActivity['intent-filter'] ?? []).some((filter) =>
|
||||
(filter.data ?? []).some(
|
||||
(d) => d.$?.['android:scheme'] === GOOGLE_REVERSE_CLIENT_ID,
|
||||
),
|
||||
);
|
||||
|
||||
if (alreadyAdded) return config;
|
||||
|
||||
mainActivity['intent-filter'] = [
|
||||
...(mainActivity['intent-filter'] ?? []),
|
||||
{
|
||||
action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
|
||||
category: [
|
||||
{ $: { 'android:name': 'android.intent.category.DEFAULT' } },
|
||||
{ $: { 'android:name': 'android.intent.category.BROWSABLE' } },
|
||||
],
|
||||
data: [{ $: { 'android:scheme': GOOGLE_REVERSE_CLIENT_ID } }],
|
||||
},
|
||||
];
|
||||
|
||||
return config;
|
||||
});
|
||||
};
|
||||
@@ -17,7 +17,8 @@ export class ApiAuthRepository implements AuthRepository {
|
||||
}
|
||||
|
||||
async loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession> {
|
||||
const { data } = await httpClient.post('/auth/oauth/google', input);
|
||||
// Backend espera snake_case: { id_token }
|
||||
const { data } = await httpClient.post('/auth/oauth/google', { id_token: input.idToken });
|
||||
return this.fetchSession(data.access_token as string, data.refresh_token as string);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ApiSpoolPresetRepository } from '@adapters/remote/ApiSpoolPresetReposit
|
||||
import {
|
||||
LoginUseCase,
|
||||
RegisterUseCase,
|
||||
GoogleLoginUseCase,
|
||||
ForgotPasswordUseCase,
|
||||
ResendVerificationUseCase,
|
||||
ResetPasswordUseCase,
|
||||
@@ -32,6 +33,7 @@ const presetRepository = new ApiSpoolPresetRepository();
|
||||
// Use cases de autenticação
|
||||
export const loginUseCase = new LoginUseCase(authRepository);
|
||||
export const registerUseCase = new RegisterUseCase(authRepository);
|
||||
export const googleLoginUseCase = new GoogleLoginUseCase(authRepository);
|
||||
export const resendVerificationUseCase = new ResendVerificationUseCase(authRepository);
|
||||
export const forgotPasswordUseCase = new ForgotPasswordUseCase(authRepository);
|
||||
export const resetPasswordUseCase = new ResetPasswordUseCase(authRepository);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { create } from 'zustand';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import axios from 'axios';
|
||||
import type { User, AuthSession } from '@domain/User';
|
||||
import { SECURE_STORE_ACCESS_TOKEN, SECURE_STORE_REFRESH_TOKEN, API_BASE_URL } from '@shared/constants';
|
||||
import { SECURE_STORE_ACCESS_TOKEN, SECURE_STORE_REFRESH_TOKEN } from '@shared/constants';
|
||||
import { httpClient } from '@adapters/remote/httpClient';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
@@ -56,27 +57,38 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
loadStoredSession: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const accessToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN);
|
||||
const refreshToken = await SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN);
|
||||
if (accessToken) {
|
||||
const { data } = await axios.get(`${API_BASE_URL}/users/me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const user: User = {
|
||||
id: String(data.id),
|
||||
email: data.email as string,
|
||||
name: (data.name as string | null) ?? null,
|
||||
googleId: (data.google_id as string | null) ?? null,
|
||||
createdAt: data.created_at as string,
|
||||
};
|
||||
set({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false });
|
||||
} else {
|
||||
const storedToken = await SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN);
|
||||
if (!storedToken) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
// Usa httpClient para que o interceptor de refresh atue automaticamente:
|
||||
// se o access token expirou mas o refresh token é válido, a sessão é renovada
|
||||
// em vez de forçar logout desnecessariamente.
|
||||
const { data } = await httpClient.get('/users/me');
|
||||
// Lê tokens após possível refresh (o interceptor pode ter atualizado o SecureStore)
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
SecureStore.getItemAsync(SECURE_STORE_ACCESS_TOKEN),
|
||||
SecureStore.getItemAsync(SECURE_STORE_REFRESH_TOKEN),
|
||||
]);
|
||||
const user: User = {
|
||||
id: String(data.id),
|
||||
email: data.email as string,
|
||||
name: (data.name as string | null) ?? null,
|
||||
googleId: (data.google_id as string | null) ?? null,
|
||||
createdAt: data.created_at as string,
|
||||
};
|
||||
set({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false });
|
||||
} catch (err) {
|
||||
// Limpa sessão apenas em falha de autenticação (401/403).
|
||||
// Erros de rede (sem conexão) não forçam logout — o usuário pode estar offline.
|
||||
const isAuthError =
|
||||
axios.isAxiosError(err) &&
|
||||
(err.response?.status === 401 || err.response?.status === 403);
|
||||
if (isAuthError || !axios.isAxiosError(err)) {
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
|
||||
}
|
||||
} catch {
|
||||
// Token inválido ou expirado — limpa a sessão
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_ACCESS_TOKEN);
|
||||
await SecureStore.deleteItemAsync(SECURE_STORE_REFRESH_TOKEN);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user