feat: implement email verification and password reset features

- Add EmailTokenService for handling email verification and password reset tokens.
- Create EmailService for sending verification and reset emails via SMTP.
- Update AuthService to handle email verification status during login.
- Modify user registration to redirect to a check email screen instead of issuing a token.
- Implement resend verification email functionality.
- Add deep link handling for email verification and password reset in the mobile app.
- Update mobile app routes and components to support new email verification flow.
- Enhance error handling for unverified emails during login attempts.
- Update configuration to include SMTP settings for email service.
This commit is contained in:
2026-03-14 23:38:15 -03:00
parent f5f4e878b4
commit d5af338ee1
38 changed files with 1146 additions and 75 deletions
+141
View File
@@ -8,6 +8,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -56,6 +68,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ar_archive_writer"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
dependencies = [
"object",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -433,6 +454,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chumsky"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
dependencies = [
"hashbrown 0.14.5",
"stacker",
]
[[package]]
name = "color_quant"
version = "1.1.0"
@@ -672,6 +703,22 @@ dependencies = [
"serde",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64 0.22.1",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -754,6 +801,12 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
@@ -992,6 +1045,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1522,6 +1585,34 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "lettre"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
dependencies = [
"async-trait",
"base64 0.22.1",
"chumsky",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"httpdate",
"idna 1.1.0",
"mime",
"nom",
"percent-encoding",
"quoted_printable",
"rustls 0.23.37",
"socket2 0.6.3",
"tokio",
"tokio-rustls 0.26.4",
"url",
"webpki-roots 1.0.6",
]
[[package]]
name = "libc"
version = "0.2.183"
@@ -1685,6 +1776,7 @@ dependencies = [
"dotenvy",
"image",
"jsonwebtoken",
"lettre",
"oauth2",
"printpdf",
"qrcode",
@@ -1884,6 +1976,15 @@ dependencies = [
"url",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2151,6 +2252,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "psm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "pxfm"
version = "0.1.28"
@@ -2245,6 +2356,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -2601,6 +2718,7 @@ version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3091,6 +3209,20 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -3931,6 +4063,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
+3
View File
@@ -65,6 +65,9 @@ base64 = "0.22"
# PDF generation
printpdf = "0.7"
# Email (SMTP)
lettre = { version = "0.11", features = ["smtp-transport", "tokio1-rustls-tls", "builder"], default-features = false }
# Async trait
async-trait = "0.1"
+172 -5
View File
@@ -23,6 +23,7 @@ Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como f
| Logging | `tracing`, `tracing-subscriber` | 0.1 |
| Validação | `validator` | 0.18 |
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
| Email (SMTP) | `lettre` (smtp-transport, tokio1-rustls-tls, builder) | 0.11 |
| Geração QR Code | `qrcode` | 0.14 |
| Geração de imagem| `image` | 0.25 |
| Encode Base64 | `base64` | 0.22 |
@@ -41,7 +42,8 @@ backend/
├── migrations/ <- SQL puro, gerenciado pelo SQLx CLI
│ ├── 20240101000001_create_users.sql
│ ├── 20240101000002_create_spool_presets.sql
── 20240101000003_create_filaments.sql
── 20240101000003_create_filaments.sql
│ └── 20240101000004_create_token_tables.sql <- password_reset_tokens + email_verification_tokens
└── src/
├── main.rs <- entry point: inicializa config, DB, router e servidor
├── config.rs <- struct Config lida de variáveis de ambiente
@@ -63,9 +65,14 @@ backend/
├── application/ <- CASOS DE USO: orquestram domínio + ports
│ ├── mod.rs
│ ├── auth_service.rs <- login, register, OAuth, refresh, logout
│ ├── email_token_service.rs <- forgot_password, verify_email, reset_password (tokens DB + email)
│ ├── filament_service.rs <- CRUD, cálculo de peso líquido, QR, SVG
│ └── spool_preset_service.rs <- CRUD presets (system read-only, user CRUD)
├── infrastructure/ <- Serviços externos (SMTP, etc.)
│ ├── mod.rs
│ └── email_service.rs <- EmailService: SMTP via lettre (STARTTLS)
└── adapters/
├── inbound/ <- HTTP: recebe requisições, delega ao application
│ ├── mod.rs
@@ -119,9 +126,19 @@ Todas as rotas são prefixadas com `/api/v1`.
| POST | `/oauth/google` | `google_oauth_handler` | Público |
| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) |
| POST | `/logout` | `logout_handler` | Autenticado |
| POST | `/forgot-password` | `forgot_password_handler` | Público |
| POST | `/verify-email` | `verify_email_handler` | Público |
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
| POST | `/resend-verification` | `resend_verification_handler` | Público (sempre 200) |
| POST | `/forgot-password` | `forgot_password_handler` | Público |
| POST | `/verify-email` | `verify_email_handler` | Público |
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
### Redirects para Deep Links — `/api/v1`
| Método | Rota | Handler | Descrição |
| ------ | ----------------- | --------------------------------- | ---------------------------------------------------------- |
| GET | `/verify-email` | `verify_email_redirect_handler` | Redireciona 302 → `{APP_SCHEME}://verify-email?token=xxx` |
| GET | `/reset-password` | `reset_password_redirect_handler` | Redireciona 302 → `{APP_SCHEME}://reset-password?token=xxx`|
> Esses endpoints são os **destinos dos links nos e-mails**. Clientes de e-mail (Gmail etc.) aceitam URLs `https://` normalmente; o backend redireciona para o deep link do app. O OS reconhece o scheme e abre o MeowSpool.
### Users — `/api/v1/users`
@@ -204,9 +221,15 @@ pub enum AppError {
#[error("validation error: {0}")]
Validation(String),
#[error("bad request: {0}")]
BadRequest(String), // token inválido, expirado, já utilizado
#[error("conflict: {0}")]
Conflict(String),
#[error("unprocessable entity: {0}")]
UnprocessableEntity(String),
#[error("internal error")]
Internal(#[from] anyhow::Error),
}
@@ -311,7 +334,26 @@ Arquivos ficam em `backend/migrations/`. Nomeie com timestamp e descrição clar
## Variáveis de Ambiente
Copie `.env.example` para `.env` antes de rodar. Veja o arquivo `.env.example` para a lista completa.
Copie `.env.example` para `.env` antes de rodar. Variáveis principais:
| Variável | Obrigatória | Padrão | Descrição |
| ------------------------- | ----------- | ----------------------- | ---------------------------------------------- |
| `DATABASE_URL` | ✅ | — | Connection string PostgreSQL |
| `JWT_SECRET` | ✅ | — | Segredo de assinatura JWT |
| `JWT_EXPIRY_SECS` | ❌ | `3600` | TTL do access token (segundos) |
| `JWT_REFRESH_EXPIRY_SECS` | ❌ | `2592000` | TTL do refresh token (segundos) |
| `GOOGLE_CLIENT_ID` | ❌ | — | Client ID OAuth Google |
| `GOOGLE_CLIENT_SECRET` | ❌ | — | Client Secret OAuth Google |
| `HOST` | ❌ | `0.0.0.0` | Endereço de bind do servidor |
| `PORT` | ❌ | `8080` | Porta do servidor |
| `APP_ENV` | ❌ | `development` | `development` ou `production` |
| `SMTP_HOST` | ❌ | `smtp.gmail.com` | Servidor SMTP |
| `SMTP_PORT` | ❌ | `587` | Porta SMTP (STARTTLS) |
| `SMTP_USER` | ❌ | — | Usuário SMTP (e-mail) |
| `SMTP_PASS` | ❌ | — | Senha / App Password SMTP |
| `EMAIL_FROM` | ❌ | `noreply@meowspool.app` | Endereço remetente dos e-mails |
| `APP_BASE_URL` | ❌ | `http://localhost:8080` | URL base usada nos links de e-mail. Em produção: `https://meowspool.felipecncloud.com/api/v1` |
| `APP_SCHEME` | ❌ | `meowspool` | Scheme do deep link do app mobile. Usado nos redirects de e-mail |
---
@@ -447,6 +489,131 @@ GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,q
## Mudanças Recentes (14/03/2026)
### ✅ Reenvio de e-mail de verificação
**Arquivos modificados**: `src/application/email_token_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/router.rs`
**Novo endpoint**: `POST /api/v1/auth/resend-verification` — body: `{ "email": "..." }`
- Sempre retorna `200 OK` (não vaza se e-mail existe ou já foi verificado)
- Ignora silenciosamente se: e-mail não cadastrado, conta já verificada
- Reutiliza `send_verification_email` internamente (gera novo token com TTL 24h)
**Novo método** (`email_token_service.rs`):
```rust
pub async fn resend_verification_email(&self, email: &str) -> Result<(), AppError> {
let Some(user) = self.user_repo.find_by_email(email).await? else { return Ok(()); };
if user.email_verified { return Ok(()); }
self.send_verification_email(user.id, email).await
}
```
---
### ✅ Registro não emite tokens — login bloqueado sem verificação de e-mail
**Arquivos modificados**: `src/application/auth_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/error.rs`
**Problema**: após o registro, o app autenticava o usuário imediatamente (emitia tokens) sem exigir verificação de e-mail. Login também aceitava contas não verificadas.
**Mudanças**:
- `AppError::EmailNotVerified` adicionado → HTTP 403, code `"EMAIL_NOT_VERIFIED"`
- `AuthService::register` passa a retornar apenas `User` (sem `TokenPair`) — o handler responde `201` sem body de auth
- `AuthService::login` verifica `user.email_verified` antes de emitir tokens:
```rust
if !user.email_verified {
return Err(AppError::EmailNotVerified);
}
```
**Fluxo resultante**:
1. Registro → `201 Created` (sem tokens) → backend envia e-mail de verificação em background
2. Login com e-mail não verificado → `403 { "code": "EMAIL_NOT_VERIFIED" }`
3. Após verificar e-mail → login funciona normalmente
---
### ✅ Redirect HTTP → Deep Link para links de e-mail
**Arquivos modificados**: `src/adapters/inbound/auth_handler.rs`, `src/router.rs`, `src/config.rs`, `.env`
**Problema**: Clientes de e-mail (Gmail, etc.) bloqueiam links com scheme customizado (`meowspool://`). O link no e-mail não abria o app.
**Solução**: O backend agora gera links `https://` nos e-mails. Ao clicar, o backend redireciona (302) para o deep link do app.
**Fluxo completo**:
```
E-mail → https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx
↓ GET (browser abre normalmente)
Backend responde 302 Location: meowspool://verify-email?token=xxx
↓ OS reconhece o scheme
App MeowSpool abre → app/verify-email.tsx
POST /auth/verify-email { token } → verifica no banco
```
**Novos handlers** (`auth_handler.rs`):
- `verify_email_redirect_handler` — `GET /api/v1/verify-email?token=xxx`
- `reset_password_redirect_handler` — `GET /api/v1/reset-password?token=xxx`
**Novo campo Config** (`config.rs`):
- `app_scheme: String` — lido de `APP_SCHEME` (padrão: `meowspool`)
**`.env` atualizado**:
- `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1` (antes: `meowspool:/`)
- `APP_SCHEME=meowspool` (novo)
---
### ✅ Fluxo completo de e-mail: verificação e reset de senha
**Arquivos criados/modificados**: `migrations/20240101000004_create_token_tables.sql`, `src/infrastructure/email_service.rs`, `src/infrastructure/mod.rs`, `src/application/email_token_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/config.rs`, `src/error.rs`, `src/main.rs`, `src/router.rs`, `Cargo.toml`
#### Migrations
Criadas as tabelas `password_reset_tokens` e `email_verification_tokens` com:
- UUID como PK (gen_random_uuid)
- `token TEXT UNIQUE` — indexado para lookup rápido
- `used_at TIMESTAMPTZ` — `NULL` = não usado; preenchido na validação para invalidar após uso
- `expires_at TIMESTAMPTZ` — TTL: 1h para reset de senha, 24h para verificação de e-mail
#### EmailService (`src/infrastructure/email_service.rs`)
Envia e-mails via SMTP com STARTTLS usando `lettre`. Métodos:
- `send_password_reset(to, reset_link)` — e-mail de redefinição de senha
- `send_email_verification(to, verify_link)` — e-mail de confirmação de conta
#### EmailTokenService (`src/application/email_token_service.rs`)
Orquestra tokens no banco + envio de e-mail. Métodos:
- `forgot_password(email)` — cria token em `password_reset_tokens`, envia e-mail. Sempre retorna `Ok` (não vaza se o e-mail existe)
- `verify_email(token)` — valida token em `email_verification_tokens`, chama `user_repo.verify_email()`, marca token como usado
- `reset_password(token, new_password)` — valida token em `password_reset_tokens`, re-hash da senha com Argon2id, atualiza usuário, marca token como usado
- `send_verification_email(user_id, email)` — cria token em `email_verification_tokens` e envia e-mail. Chamado após o registro
#### Handlers implementados
Anteriormente retornavam `200 OK` sem lógica. Agora delegam ao `EmailTokenService`:
- `forgot_password_handler` — POST `/auth/forgot-password`
- `verify_email_handler` — POST `/auth/verify-email`
- `reset_password_handler` — POST `/auth/reset-password`
#### Registro envia e-mail de verificação
`register_handler` chama `email_token_service.send_verification_email()` via `tokio::spawn` (background) após criar o usuário — o cadastro responde imediatamente sem esperar o SMTP.
#### AppError::BadRequest adicionado
Nova variante para erros previsíveis do usuário (token inválido, expirado, já usado) → HTTP 400.
#### Configuração `APP_BASE_URL`
- Em produção: `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1`
- Gera links nos e-mails como `https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx`
- O backend redireciona esse GET para o deep link via `APP_SCHEME`
---
### ✅ Suporte a Impressoras Pequenas — Niimbot e layout adaptativo
**Arquivos alterados**: `filament_handler.rs`, `filament_service.rs`
@@ -1,7 +1,7 @@
info:
name: Forgot Password
type: http
seq: 5
seq: 6
http:
method: POST
@@ -14,6 +14,13 @@ http:
"password": "12345678"
}
script:
res: |
if (res.status === 200) {
bru.setEnvVar("access_token", res.getBody().access_token);
bru.setEnvVar("refresh_token", res.getBody().refresh_token);
}
settings:
encodeUrl: true
timeout: 0
@@ -1,18 +1,11 @@
info:
name: Logout
type: http
seq: 2
seq: 5
http:
method: POST
url: "{{base_url}}/api/v1/auth/logout"
body:
type: json
data: |-
{
"email": "felipe@felipecncloud.com",
"password": "l11f06c10"
}
auth:
type: bearer
token: "{{access_token}}"
@@ -1,7 +1,7 @@
info:
name: OAuth Google
type: http
seq: 8
seq: 3
http:
method: POST
@@ -13,6 +13,13 @@ http:
"id_token": "{{google_id_token}}"
}
script:
res: |
if (res.status === 200) {
bru.setEnvVar("access_token", res.getBody().access_token);
bru.setEnvVar("refresh_token", res.getBody().refresh_token);
}
settings:
encodeUrl: true
timeout: 0
@@ -13,6 +13,13 @@ http:
"refresh_token": "{{refresh_token}}"
}
script:
res: |
if (res.status === 200) {
bru.setEnvVar("access_token", res.getBody().access_token);
bru.setEnvVar("refresh_token", res.getBody().refresh_token);
}
settings:
encodeUrl: true
timeout: 0
@@ -10,7 +10,7 @@ http:
type: json
data: |-
{
"email": "felipe@felipecncloud.com",
"email": "felipecaninn18@gmail.com",
"password": "12345678"
}
@@ -1,7 +1,7 @@
info:
name: Reset Password
type: http
seq: 7
seq: 8
http:
method: POST
@@ -1,7 +1,7 @@
info:
name: Verify Email
type: http
seq: 6
seq: 7
http:
method: POST
@@ -10,16 +10,16 @@ http:
type: json
data: |-
{
"material": "PLA",
"brand": "Bambu",
"model": "PLA Basic",
"material": "PETG",
"brand": "Elegoo",
"model": "Rapid",
"color_hex": "#FF5733",
"spool_preset_id": "{{preset_id}}",
"total_weight_g": 1250,
"temp_hotend_c": 220,
"temp_bed_c": 60,
"flow_factor_pct": 1.0,
"notes": "Filamento de teste"
"total_weight_g": 527,
"temp_hotend_c": 250,
"temp_bed_c": 70,
"flow_factor_pct": 0.0033,
"notes": "Filamento"
}
auth:
type: bearer
@@ -0,0 +1,17 @@
info:
name: Delete Me
type: http
seq: 3
http:
method: DELETE
url: "{{base_url}}/api/v1/users/me"
auth:
type: bearer
token: "{{access_token}}"
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
@@ -10,3 +10,9 @@ variables:
value: 07188f3b-2b74-4251-88bc-037de7628d65
- name: filament_id
value: ""
- name: google_id_token
value: ""
- name: email_verify_token
value: 0351fde6cc2546499c2910fe109528fc
- name: reset_token
value: ""
@@ -0,0 +1,19 @@
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON password_reset_tokens (token);
CREATE TABLE email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON email_verification_tokens (token);
+65 -15
View File
@@ -1,4 +1,4 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use axum::{extract::{Query, State}, http::{header, StatusCode}, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -40,11 +40,22 @@ pub struct ForgotPasswordRequest {
pub email: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct ResendVerificationRequest {
#[validate(email)]
pub email: String,
}
#[derive(Debug, Deserialize)]
pub struct VerifyEmailRequest {
pub token: String,
}
#[derive(Debug, Deserialize)]
pub struct TokenQueryParams {
pub token: String,
}
#[derive(Debug, Deserialize, Validate)]
pub struct ResetPasswordRequest {
pub token: String,
@@ -68,15 +79,18 @@ pub async fn register_handler(
Json(req): Json<RegisterRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let tokens = state.auth_service.register(req.email, req.password).await?;
Ok((
StatusCode::CREATED,
Json(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: "Bearer",
}),
))
let email = req.email.clone();
let user = state.auth_service.register(req.email, req.password).await?;
// Envia email de verificação em background para não bloquear a resposta
let ets = state.email_token_service.clone();
tokio::spawn(async move {
if let Err(e) = ets.send_verification_email(user.id, &email).await {
tracing::warn!(error = %e, "Failed to send verification email");
}
});
Ok(StatusCode::CREATED)
}
pub async fn login_handler(
@@ -124,30 +138,66 @@ pub async fn logout_handler() -> impl IntoResponse {
StatusCode::NO_CONTENT
}
pub async fn resend_verification_handler(
State(state): State<AppState>,
Json(req): Json<ResendVerificationRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
state.email_token_service.resend_verification_email(&req.email).await?;
Ok(StatusCode::OK)
}
pub async fn forgot_password_handler(
State(state): State<AppState>,
Json(req): Json<ForgotPasswordRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
// TODO: gerar token de reset e enviar email
// Retornamos sempre 200 para não vazar informação sobre emails cadastrados
// Sempre retorna 200 para não vazar informação sobre emails cadastrados
state.email_token_service.forgot_password(&req.email).await?;
Ok(StatusCode::OK)
}
pub async fn verify_email_handler(
Json(_req): Json<VerifyEmailRequest>,
State(state): State<AppState>,
Json(req): Json<VerifyEmailRequest>,
) -> Result<impl IntoResponse, AppError> {
// TODO: validar token e marcar email_verified = true
state.email_token_service.verify_email(&req.token).await?;
Ok(StatusCode::OK)
}
pub async fn reset_password_handler(
State(state): State<AppState>,
Json(req): Json<ResetPasswordRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
// TODO: validar token de reset e atualizar senha
state.email_token_service.reset_password(&req.token, &req.new_password).await?;
Ok(StatusCode::OK)
}
/// GET /verify-email?token=xxx → redireciona para o deep link do app
pub async fn verify_email_redirect_handler(
State(state): State<AppState>,
Query(params): Query<TokenQueryParams>,
) -> impl IntoResponse {
let location = format!(
"{}://verify-email?token={}",
state.config.app_scheme, params.token
);
(StatusCode::FOUND, [(header::LOCATION, location)])
}
/// GET /reset-password?token=xxx → redireciona para o deep link do app
pub async fn reset_password_redirect_handler(
State(state): State<AppState>,
Query(params): Query<TokenQueryParams>,
) -> impl IntoResponse {
let location = format!(
"{}://reset-password?token={}",
state.config.app_scheme, params.token
);
(StatusCode::FOUND, [(header::LOCATION, location)])
}
// -------------------------------------------------------------------------
// Helper: verificação de Google ID Token
// -------------------------------------------------------------------------
+15 -2
View File
@@ -1,4 +1,4 @@
use axum::{extract::State, response::IntoResponse, Extension, Json};
use axum::{extract::State, http::StatusCode, response::IntoResponse, Extension, Json};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
@@ -68,5 +68,18 @@ pub async fn update_me_handler(
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
// TODO: implementar atualização de perfil
Ok(axum::http::StatusCode::OK)
Ok(StatusCode::OK)
}
pub async fn delete_me_handler(
State(state): State<AppState>,
Extension(current_user): Extension<CurrentUser>,
) -> Result<impl IntoResponse, AppError> {
state
.auth_service
.user_repo_ref()
.delete(current_user.id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -100,6 +100,15 @@ impl UserRepository for PostgresUserRepository {
Ok(())
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(self.db.as_ref())
.await?;
Ok(())
}
}
// -------------------------------------------------------------------------
+8 -6
View File
@@ -60,16 +60,14 @@ impl AuthService {
// Registro com email/senha
// -------------------------------------------------------------------------
pub async fn register(&self, email: String, password: String) -> Result<TokenPair, AppError> {
if let Some(_) = self.user_repo.find_by_email(&email).await? {
pub async fn register(&self, email: String, password: String) -> Result<User, AppError> {
if self.user_repo.find_by_email(&email).await?.is_some() {
return Err(AppError::Conflict("email already registered".into()));
}
let password_hash = hash_password(&password)?;
let user = User::new_with_password(email, password_hash);
let user = self.user_repo.create(&user).await?;
self.issue_token_pair(&user)
let new_user = User::new_with_password(email, password_hash);
Ok(self.user_repo.create(&new_user).await?)
}
// -------------------------------------------------------------------------
@@ -86,6 +84,10 @@ impl AuthService {
let hash = user.password_hash.as_deref().ok_or(AppError::Unauthorized)?;
verify_password(&password, hash)?;
if !user.email_verified {
return Err(AppError::EmailNotVerified);
}
self.issue_token_pair(&user)
}
@@ -0,0 +1,189 @@
use std::sync::Arc;
use sqlx::PgPool;
use uuid::Uuid;
use crate::{
application::auth_service::hash_password,
config::Config,
error::AppError,
infrastructure::EmailService,
ports::UserRepository,
};
pub struct EmailTokenService {
db: Arc<PgPool>,
user_repo: Arc<dyn UserRepository>,
email_svc: Arc<EmailService>,
config: Config,
}
impl EmailTokenService {
pub fn new(
db: Arc<PgPool>,
user_repo: Arc<dyn UserRepository>,
email_svc: Arc<EmailService>,
config: Config,
) -> Self {
Self { db, user_repo, email_svc, config }
}
// -------------------------------------------------------------------------
// Forgot password: gera token e envia email
// -------------------------------------------------------------------------
pub async fn forgot_password(&self, email: &str) -> Result<(), AppError> {
// Não revelamos se o email existe ou não
let Some(user) = self.user_repo.find_by_email(email).await? else {
return Ok(());
};
let token = generate_token();
let expires_at = time::OffsetDateTime::now_utc() + time::Duration::hours(1);
sqlx::query(
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)",
)
.bind(user.id)
.bind(&token)
.bind(expires_at)
.execute(self.db.as_ref())
.await?;
let reset_link = format!(
"{}/reset-password?token={}",
self.config.app_base_url, token
);
self.email_svc.send_password_reset(email, &reset_link).await?;
Ok(())
}
// -------------------------------------------------------------------------
// Verify email: valida token e marca email_verified = true
// -------------------------------------------------------------------------
pub async fn verify_email(&self, token: &str) -> Result<(), AppError> {
let row = sqlx::query_as::<_, TokenRow>(
"SELECT id, user_id, used_at, expires_at FROM email_verification_tokens WHERE token = $1",
)
.bind(token)
.fetch_optional(self.db.as_ref())
.await?
.ok_or(AppError::BadRequest("Token inválido".into()))?;
validate_token(&row)?;
self.user_repo.verify_email(row.user_id).await?;
sqlx::query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1",
)
.bind(row.id)
.execute(self.db.as_ref())
.await?;
Ok(())
}
// -------------------------------------------------------------------------
// Reset password: valida token e atualiza senha
// -------------------------------------------------------------------------
pub async fn reset_password(&self, token: &str, new_password: &str) -> Result<(), AppError> {
let row = sqlx::query_as::<_, TokenRow>(
"SELECT id, user_id, used_at, expires_at FROM password_reset_tokens WHERE token = $1",
)
.bind(token)
.fetch_optional(self.db.as_ref())
.await?
.ok_or(AppError::BadRequest("Token inválido".into()))?;
validate_token(&row)?;
let mut user = self
.user_repo
.find_by_id(row.user_id)
.await?
.ok_or(AppError::BadRequest("Usuário não encontrado".into()))?;
user.password_hash = Some(hash_password(new_password)?);
self.user_repo.update(&user).await?;
sqlx::query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1",
)
.bind(row.id)
.execute(self.db.as_ref())
.await?;
Ok(())
}
// -------------------------------------------------------------------------
// Geração de token de verificação de email (chamado no registro)
// -------------------------------------------------------------------------
// -------------------------------------------------------------------------
// Reenvio de e-mail de verificação (endpoint público)
// -------------------------------------------------------------------------
pub async fn resend_verification_email(&self, email: &str) -> Result<(), AppError> {
// Não revela se o e-mail existe ou se já foi verificado
let Some(user) = self.user_repo.find_by_email(email).await? else {
return Ok(());
};
if user.email_verified {
return Ok(());
}
self.send_verification_email(user.id, email).await
}
pub async fn send_verification_email(&self, user_id: Uuid, email: &str) -> Result<(), AppError> {
let token = generate_token();
let expires_at = time::OffsetDateTime::now_utc() + time::Duration::hours(24);
sqlx::query(
"INSERT INTO email_verification_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)",
)
.bind(user_id)
.bind(&token)
.bind(expires_at)
.execute(self.db.as_ref())
.await?;
let verify_link = format!(
"{}/verify-email?token={}",
self.config.app_base_url, token
);
self.email_svc.send_email_verification(email, &verify_link).await?;
Ok(())
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
fn generate_token() -> String {
Uuid::new_v4().to_string().replace('-', "")
}
#[derive(sqlx::FromRow)]
struct TokenRow {
id: Uuid,
user_id: Uuid,
used_at: Option<time::OffsetDateTime>,
expires_at: time::OffsetDateTime,
}
fn validate_token(row: &TokenRow) -> Result<(), AppError> {
if row.used_at.is_some() {
return Err(AppError::BadRequest("Token já utilizado".into()));
}
if time::OffsetDateTime::now_utc() > row.expires_at {
return Err(AppError::BadRequest("Token expirado".into()));
}
Ok(())
}
+2
View File
@@ -1,7 +1,9 @@
pub mod auth_service;
pub mod email_token_service;
pub mod filament_service;
pub mod spool_preset_service;
pub use auth_service::AuthService;
pub use email_token_service::EmailTokenService;
pub use filament_service::FilamentService;
pub use spool_preset_service::SpoolPresetService;
+15
View File
@@ -12,6 +12,14 @@ pub struct Config {
pub host: String,
pub port: u16,
pub app_env: AppEnv,
// Email (SMTP)
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_user: String,
pub smtp_pass: String,
pub email_from: String,
pub app_base_url: String,
pub app_scheme: String,
}
#[derive(Debug, Clone, PartialEq)]
@@ -32,6 +40,13 @@ impl Config {
host: optional_string_default("HOST", "0.0.0.0"),
port: optional("PORT", 8080)?,
app_env: parse_app_env(),
smtp_host: optional_string_default("SMTP_HOST", "smtp.gmail.com"),
smtp_port: optional("SMTP_PORT", 587)?,
smtp_user: optional_string("SMTP_USER"),
smtp_pass: optional_string("SMTP_PASS"),
email_from: optional_string_default("EMAIL_FROM", "noreply@meowspool.app"),
app_base_url: optional_string_default("APP_BASE_URL", "http://localhost:8080"),
app_scheme: optional_string_default("APP_SCHEME", "meowspool"),
})
}
+8
View File
@@ -18,9 +18,15 @@ pub enum AppError {
#[error("forbidden")]
Forbidden,
#[error("email not verified")]
EmailNotVerified,
#[error("validation error: {0}")]
Validation(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("conflict: {0}")]
Conflict(String),
@@ -37,7 +43,9 @@ impl AppError {
Self::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND"),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED"),
Self::Forbidden => (StatusCode::FORBIDDEN, "FORBIDDEN"),
Self::EmailNotVerified => (StatusCode::FORBIDDEN, "EMAIL_NOT_VERIFIED"),
Self::Validation(_) => (StatusCode::BAD_REQUEST, "VALIDATION_ERROR"),
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, "BAD_REQUEST"),
Self::Conflict(_) => (StatusCode::CONFLICT, "CONFLICT"),
Self::UnprocessableEntity(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UNPROCESSABLE_ENTITY"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"),
@@ -0,0 +1,67 @@
use lettre::{
message::header::ContentType,
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use crate::{config::Config, error::AppError};
pub struct EmailService {
mailer: AsyncSmtpTransport<Tokio1Executor>,
from: String,
}
impl EmailService {
pub fn new(config: &Config) -> Result<Self, AppError> {
let creds = Credentials::new(config.smtp_user.clone(), config.smtp_pass.clone());
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)
.map_err(|e| AppError::Internal(anyhow::anyhow!("SMTP config error: {e}")))?
.port(config.smtp_port)
.credentials(creds)
.build();
Ok(Self {
mailer,
from: config.email_from.clone(),
})
}
pub async fn send_password_reset(&self, to: &str, reset_link: &str) -> Result<(), AppError> {
let body = format!(
"Você solicitou a redefinição de senha do MeowSpool.\n\nClique no link abaixo para redefinir sua senha (válido por 1 hora):\n\n{reset_link}\n\nSe você não solicitou isso, ignore este email."
);
self.send(to, "Redefinição de senha — MeowSpool", &body).await
}
pub async fn send_email_verification(&self, to: &str, verify_link: &str) -> Result<(), AppError> {
let body = format!(
"Bem-vindo ao MeowSpool! Confirme seu endereço de email clicando no link abaixo:\n\n{verify_link}\n\nO link expira em 24 horas."
);
self.send(to, "Confirme seu email — MeowSpool", &body).await
}
async fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), AppError> {
let email = Message::builder()
.from(
self.from
.parse()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid from address: {e}")))?,
)
.to(to
.parse()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Invalid to address: {e}")))?)
.subject(subject)
.header(ContentType::TEXT_PLAIN)
.body(body.to_string())
.map_err(|e| AppError::Internal(anyhow::anyhow!("Email build error: {e}")))?;
AsyncTransport::send(&self.mailer, email)
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("SMTP send error: {e}")))?;
Ok(())
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod email_service;
pub use email_service::EmailService;
+1
View File
@@ -8,6 +8,7 @@ mod application;
mod config;
mod domain;
mod error;
mod infrastructure;
mod ports;
mod router;
+1
View File
@@ -13,4 +13,5 @@ pub trait UserRepository: Send + Sync {
async fn create(&self, user: &User) -> Result<User, AppError>;
async fn update(&self, user: &User) -> Result<User, AppError>;
async fn verify_email(&self, id: Uuid) -> Result<(), AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
}
+19 -2
View File
@@ -18,8 +18,9 @@ use crate::{
PostgresUserRepository,
},
},
application::{AuthService, FilamentService, SpoolPresetService},
application::{AuthService, EmailTokenService, FilamentService, SpoolPresetService},
config::Config,
infrastructure::EmailService,
ports::{FilamentRepository, SpoolPresetRepository, UserRepository},
};
@@ -27,6 +28,7 @@ use crate::{
#[derive(Clone)]
pub struct AppState {
pub auth_service: Arc<AuthService>,
pub email_token_service: Arc<EmailTokenService>,
pub filament_service: Arc<FilamentService>,
pub spool_preset_service: Arc<SpoolPresetService>,
pub config: Config,
@@ -46,6 +48,15 @@ pub fn build(db: PgPool, config: Config) -> Router {
Arc::clone(&user_repo),
config.clone(),
));
let email_svc = Arc::new(
EmailService::new(&config).expect("Failed to configure SMTP"),
);
let email_token_service = Arc::new(EmailTokenService::new(
Arc::clone(&db),
Arc::clone(&user_repo),
Arc::clone(&email_svc),
config.clone(),
));
let filament_service = Arc::new(FilamentService::new(
Arc::clone(&filament_repo),
Arc::clone(&preset_repo),
@@ -54,6 +65,7 @@ pub fn build(db: PgPool, config: Config) -> Router {
let state = AppState {
auth_service,
email_token_service,
filament_service,
spool_preset_service,
config: config.clone(),
@@ -65,9 +77,13 @@ pub fn build(db: PgPool, config: Config) -> Router {
.route("/auth/login", post(auth_handler::login_handler))
.route("/auth/oauth/google", post(auth_handler::google_oauth_handler))
.route("/auth/refresh", post(auth_handler::refresh_token_handler))
.route("/auth/resend-verification", post(auth_handler::resend_verification_handler))
.route("/auth/forgot-password", post(auth_handler::forgot_password_handler))
.route("/auth/verify-email", post(auth_handler::verify_email_handler))
.route("/auth/reset-password", post(auth_handler::reset_password_handler));
.route("/auth/reset-password", post(auth_handler::reset_password_handler))
// Redirects para deep links (usados nos links de email)
.route("/verify-email", get(auth_handler::verify_email_redirect_handler))
.route("/reset-password", get(auth_handler::reset_password_redirect_handler));
// Rotas protegidas (requerem JWT válido)
let protected_routes = Router::new()
@@ -75,6 +91,7 @@ pub fn build(db: PgPool, config: Config) -> Router {
// Users
.route("/users/me", get(user_handler::get_me_handler))
.route("/users/me", put(user_handler::update_me_handler))
.route("/users/me", delete(user_handler::delete_me_handler))
// Dashboard
.route("/dashboard", get(filament_handler::dashboard_handler))
// Filaments
+120 -7
View File
@@ -85,11 +85,12 @@ mobile/
│ │ ├── login.tsx ← G3-0
│ │ ├── register.tsx ← IX-0
│ │ ├── forgot-password.tsx ← L2-0
│ │ ├── verify-email.tsx ← RU-0
│ │ ├── check-email.tsx ← RU-0 Tela informativa pós-cadastro ("Confirme seu e-mail")
│ │ ├── reset-password.tsx ← 117-0
│ │ └── password-reset-done.tsx ← 135-0
│ ├── filament/
│ │ └── [id].tsx ← Deep link handler: redireciona meowspool://filament/{id} → /(app)/inventory/{id}
│ ├── verify-email.tsx ← Deep link handler: meowspool://verify-email?token=xxx → chama API e exibe resultado
│ └── (app)/
│ ├── _layout.tsx ← Stack autenticado
│ ├── (tabs)/
@@ -138,7 +139,7 @@ mobile/
│ ├── preset/
│ │ └── PresetUseCases.ts ← List, Create, Update, Delete
│ ├── auth/
│ │ └── AuthUseCases.ts ← Login, Register, Google, Logout, etc.
│ │ └── AuthUseCases.ts ← Login, Register, Google, Logout, ForgotPassword, ResendVerification, VerifyEmail, ResetPassword
│ └── nfc/
│ ├── ReadNFCTagUseCase.ts ← lê URI, valida schema meowspool://, extrai filament ID
│ └── WriteNFCTagUseCase.ts ← monta meowspool://filament/{id} e grava na tag
@@ -222,12 +223,22 @@ Arquivo: `src/shared/theme.ts`
### Deep links
- Scheme: `meowspool://`
- Filamento: `meowspool://filament/<id>``/(app)/inventory/<id>` (singular, alinhado com backend)
- O redirecionamento é feito pela rota `app/filament/[id].tsx` com `<Redirect>` do expo-router — garante que o deep link abrido externamente (QR Code, NFC, link compartilhado) sempre chegue na tela correta
- Scheme: `meowspool://` (configurado em `app.json` como `scheme: "meowspool"`)
- **Filamento**: `meowspool://filament/<id>``/(app)/inventory/<id>`
- Handler: `app/filament/[id].tsx` com `<Redirect>` do expo-router
- Mesmo link usado em QR Code, NFC e links compartilhados
- **Verificação de e-mail**: `meowspool://verify-email?token=<token>`
- Handler: `app/verify-email.tsx` — chama `VerifyEmailUseCase.execute(token)` automaticamente
- Exibe estados: carregando → sucesso → erro (token inválido/expirado)
- Redireciona para `/(auth)/login` ao concluir
- **Reset de senha**: `meowspool://reset-password?token=<token>`
- Handler: `app/(auth)/reset-password.tsx` — lê `token` via `useLocalSearchParams`
- O scanner (`scanner.tsx`) faz match via `/meowspool:\/\/filament\/([^/]+)/` e navega para `/(app)/inventory/<id>`
- O QR Code de cada filamento exibe `meowspool://filament/<id>` usando `react-native-qrcode-svg`
- As tags NFC NTAG215 gravam a mesma URI `meowspool://filament/<id>` como NDEF URI record — mesmo deep link, infraestrutura compartilhada
- As tags NFC NTAG215 gravam a URI `meowspool://filament/<id>` como NDEF URI record
> **Links de e-mail**: o backend gera links `https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx` (aceitos por clientes de e-mail). O backend redireciona (302) para `meowspool://verify-email?token=xxx`. O OS reconhece o scheme e abre o app.
> - `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1`
> - `APP_SCHEME=meowspool`
---
@@ -309,6 +320,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
| POST | `/auth/google` | OAuth Google |
| POST | `/auth/refresh` | Refresh token |
| POST | `/auth/logout` | Logout |
| POST | `/auth/resend-verification` | Reenviar e-mail de verificação |
| POST | `/auth/forgot-password` | Solicitar reset de senha |
| POST | `/auth/reset-password` | Confirmar reset com token |
| POST | `/auth/verify-email` | Verificar e-mail com código |
@@ -357,6 +369,7 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
- [x] NFC gravação NTAG215 → `filaments/[id]/write-nfc.tsx`
- [ ] 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`
- [ ] Testes de integração com Jest + Testing Library
@@ -364,6 +377,106 @@ Base: `EXPO_PUBLIC_API_URL` (padrão: `http://localhost:3000/api/v1`)
## Mudanças Recentes (14/03/2026)
### ✅ Reenvio de e-mail de verificação
**Arquivos modificados**: `src/ports/AuthRepository.ts`, `src/adapters/remote/ApiAuthRepository.ts`, `src/application/auth/AuthUseCases.ts`, `src/infrastructure/container.ts`, `app/(auth)/check-email.tsx`, `app/(auth)/register.tsx`, `app/(auth)/login.tsx`
**Fluxo**:
- `register.tsx` e `login.tsx` passam o email como query param: `/(auth)/check-email?email=xxx`
- `check-email.tsx``email` via `useLocalSearchParams` e exibe botão "Reenviar e-mail"
- Cooldown de 60s após cada envio para evitar spam
- Feedback visual: "E-mail reenviado! Verifique sua caixa de entrada."
- Botão só aparece se `email` estiver disponível nos params
**Cadeia adicionada**:
- `AuthRepository.resendVerification(email)` — novo método no port
- `ApiAuthRepository.resendVerification``POST /auth/resend-verification`
- `ResendVerificationUseCase` — valida email não vazio e delega ao repo
- `resendVerificationUseCase` exportado do `container.ts`
---
### ✅ Verificação de e-mail obrigatória antes do login
**Arquivos modificados**: `src/ports/AuthRepository.ts`, `src/adapters/remote/ApiAuthRepository.ts`, `src/application/auth/AuthUseCases.ts`, `app/(auth)/register.tsx`, `app/(auth)/login.tsx`, `app/_layout.tsx`
**Renomeação**: `app/(auth)/verify-email.tsx``app/(auth)/check-email.tsx`
- **Motivo**: no expo-router, grupos `(auth)` são transparentes na URL. `app/(auth)/verify-email.tsx` e `app/verify-email.tsx` competiam pelo mesmo path `/verify-email`, fazendo o deep link cair na tela informativa em vez do handler de verificação.
**Mudanças**:
- `RegisterUseCase.execute` / `ApiAuthRepository.register` / `AuthRepository.register` — retornam `void` (backend não emite mais tokens no registro)
- `register.tsx` — remove `setSession`, navega para `/(auth)/check-email` após cadastro (usuário fica não autenticado)
- `login.tsx` — detecta `EMAIL_NOT_VERIFIED` (403 do backend) e exibe alerta com link para `/(auth)/check-email`:
```ts
if (axios.isAxiosError(err) && err.response?.data?.code === 'EMAIL_NOT_VERIFIED') {
Alert.alert('E-mail não verificado', '...', [
{ text: 'OK', onPress: () => router.push('/(auth)/check-email') },
]);
}
```
- `app/_layout.tsx` — auth guard agora exclui rotas públicas da raiz do redirect para login:
```ts
const inPublicRoute = segments[0] === 'verify-email' || segments[0] === 'filament';
// ...
} else if (!isAuthenticated && !inAuthGroup && !inPublicRoute) {
router.replace('/(auth)/login');
}
```
**Sem esse fix**, o guard redirecionava `verify-email` para login antes de o usuário ver a tela de sucesso.
**Rotas públicas da raiz** (não sofrem redirect do auth guard):
| Rota | Arquivo | Função |
|---|---|---|
| `/verify-email` | `app/verify-email.tsx` | Handler deep link — verifica e-mail e exibe sucesso/erro |
| `/filament` | `app/filament/[id].tsx` | Handler deep link — redireciona para detalhe do filamento |
---
### ✅ Redirect HTTP → Deep Link (fix compatibilidade com clientes de e-mail)
**Contexto**: clientes de e-mail (Gmail) bloqueiam links com scheme customizado (`meowspool://`). O link não abria o app.
**Solução implementada no backend**: o e-mail agora contém link `https://`. O backend (GET `/api/v1/verify-email?token=xxx`) redireciona 302 → `meowspool://verify-email?token=xxx`. O OS reconhece o scheme e abre o app normalmente.
**Impacto no mobile**: nenhuma alteração necessária no app — `app/verify-email.tsx` continua recebendo o deep link e chamando o backend via POST como antes.
---
### ✅ Deep Link de Verificação de E-mail — `app/verify-email.tsx`
**Arquivos criados/modificados**: `app/verify-email.tsx`, `app/_layout.tsx`, `src/application/auth/AuthUseCases.ts`, `src/infrastructure/container.ts`
#### Fluxo
1. Backend envia e-mail com link `https://meowspool.felipecncloud.com/api/v1/verify-email?token=<token>` após o registro
2. Usuário toca no link → browser abre → backend redireciona 302 → `meowspool://verify-email?token=<token>`
3. OS reconhece o scheme → expo-router roteia para `app/verify-email.tsx`
4. A tela lê `token` via `useLocalSearchParams` e chama `VerifyEmailUseCase.execute(token)` em `useEffect`
5. Exibe estado **loading** durante a chamada, **sucesso** ou **erro** (token inválido / expirado / já usado)
6. Botão redireciona para `/(auth)/login`
#### `VerifyEmailUseCase`
Adicionado em `src/application/auth/AuthUseCases.ts`:
```ts
export class VerifyEmailUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(token: string): Promise<void> {
if (!token) throw new Error('Token inválido.');
await this.authRepo.verifyEmail(token);
}
}
```
- `ApiAuthRepository.verifyEmail(token)` já existia (`POST /auth/verify-email` com `{ token }`)
- `verifyEmailUseCase` exportado do `container.ts`
- Rota `verify-email` registrada no `Stack` do `app/_layout.tsx`
---
### ✅ Deep Link Handler — `app/filament/[id].tsx`
Criada rota `app/filament/[id].tsx` para tratar deep links abertos externamente (QR Code, NFC, mensagem compartilhada). Sem essa rota, o expo-router exibia "Unmatched Route" ao abrir `meowspool://filament/{id}` fora do app.
@@ -1,15 +1,43 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router';
import { Screen } from '@presentation/components/layout/Screen';
import { Header } from '@presentation/components/layout/Header';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
import { resendVerificationUseCase } from '@infrastructure/container';
const COOLDOWN_SECS = 60;
/**
* Tela de Verificação de E-mail RU-0
* Mostrada após o cadastro bem-sucedido.
*/
export default function VerifyEmailScreen(): React.ReactElement {
export default function CheckEmailScreen(): React.ReactElement {
const { email } = useLocalSearchParams<{ email: string }>();
const [isLoading, setIsLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [sent, setSent] = useState(false);
useEffect(() => {
if (cooldown <= 0) return;
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
async function handleResend(): Promise<void> {
if (!email || cooldown > 0) return;
setIsLoading(true);
try {
await resendVerificationUseCase.execute(email);
setSent(true);
setCooldown(COOLDOWN_SECS);
} finally {
setIsLoading(false);
}
}
return (
<Screen>
<Header title="Verificar e-mail" showBack />
@@ -24,6 +52,27 @@ export default function VerifyEmailScreen(): React.ReactElement {
Enviamos um link de verificação para o seu e-mail. Clique no link para ativar sua conta e poder fazer login.
</Text>
{email && (
<Button
label={
cooldown > 0
? `Reenviar em ${cooldown}s`
: sent
? 'Reenviar e-mail'
: 'Reenviar e-mail'
}
variant="secondary"
onPress={handleResend}
isLoading={isLoading}
disabled={cooldown > 0}
style={styles.resendButton}
/>
)}
{sent && cooldown > 0 && (
<Text style={styles.sentText}>E-mail reenviado! Verifique sua caixa de entrada.</Text>
)}
<View style={styles.hint}>
<Ionicons name="information-circle-outline" size={16} color={colors.textSecondary} />
<Text style={styles.hintText}>Não recebeu? Verifique sua pasta de spam.</Text>
@@ -64,11 +113,20 @@ const styles = StyleSheet.create({
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
resendButton: {
width: '100%',
},
sentText: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.sm,
color: colors.accent,
textAlign: 'center',
},
hint: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing[2],
marginTop: spacing[4],
marginTop: spacing[2],
},
hintText: {
fontFamily: typography.fontFamily.ui,
+10 -2
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import axios from 'axios';
import { Link, useRouter } from 'expo-router';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -42,8 +43,15 @@ export default function LoginScreen(): React.ReactElement {
await setSession(session);
router.replace('/(app)/(tabs)/home');
} catch (err) {
console.error('login error', err);
Alert.alert('Erro', 'E-mail ou senha incorretos.');
if (axios.isAxiosError(err) && err.response?.data?.code === 'EMAIL_NOT_VERIFIED') {
Alert.alert(
'E-mail não verificado',
'Confirme seu e-mail antes de entrar. Verifique sua caixa de entrada.',
[{ text: 'OK', onPress: () => router.push(`/(auth)/check-email?email=${encodeURIComponent(data.email)}`) }],
);
} else {
Alert.alert('Erro', 'E-mail ou senha incorretos.');
}
} finally {
setIsLoading(false);
}
+2 -5
View File
@@ -11,7 +11,6 @@ import { Input } from '@presentation/components/ui/Input';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing } from '@shared/theme';
import { registerUseCase } from '@infrastructure/container';
import { useAuthStore } from '@store/authStore';
const schema = z.object({
email: z.string().email('E-mail inválido'),
@@ -31,7 +30,6 @@ type FormData = z.infer<typeof schema>;
*/
export default function RegisterScreen(): React.ReactElement {
const router = useRouter();
const setSession = useAuthStore((s) => s.setSession);
const [isLoading, setIsLoading] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
@@ -42,9 +40,8 @@ export default function RegisterScreen(): React.ReactElement {
async function onSubmit(data: FormData): Promise<void> {
setIsLoading(true);
try {
const session = await registerUseCase.execute({ email: data.email, password: data.password });
await setSession(session);
router.replace('/(auth)/verify-email');
await registerUseCase.execute({ email: data.email, password: data.password });
router.replace(`/(auth)/check-email?email=${encodeURIComponent(data.email)}`);
} catch {
Alert.alert('Erro', 'Não foi possível criar sua conta.');
} finally {
+4 -1
View File
@@ -19,9 +19,10 @@ export default function RootLayout(): React.ReactElement | null {
useEffect(() => {
if (isLoading) return;
const inAuthGroup = segments[0] === '(auth)';
const inPublicRoute = segments[0] === 'verify-email' || segments[0] === 'filament';
if (isAuthenticated && inAuthGroup) {
router.replace('/(app)/(tabs)/home');
} else if (!isAuthenticated && !inAuthGroup) {
} else if (!isAuthenticated && !inAuthGroup && !inPublicRoute) {
router.replace('/(auth)/login');
}
}, [isAuthenticated, isLoading, segments, router]);
@@ -35,6 +36,8 @@ export default function RootLayout(): React.ReactElement | null {
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
<Stack.Screen name="verify-email" />
<Stack.Screen name="filament/[id]" />
</Stack>
</>
);
+116
View File
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Screen } from '@presentation/components/layout/Screen';
import { Button } from '@presentation/components/ui/Button';
import { colors, typography, spacing, radius } from '@shared/theme';
import { verifyEmailUseCase } from '@infrastructure/container';
type Status = 'loading' | 'success' | 'error';
/**
* Handler de deep link: meowspool://verify-email?token=xxx
* Chama o backend para confirmar o e-mail e exibe o resultado.
*/
export default function VerifyEmailDeepLink(): React.ReactElement {
const { token } = useLocalSearchParams<{ token: string }>();
const router = useRouter();
const [status, setStatus] = useState<Status>('loading');
const [errorMessage, setErrorMessage] = useState('Não foi possível verificar o e-mail.');
useEffect(() => {
if (!token) {
setErrorMessage('Link inválido ou expirado.');
setStatus('error');
return;
}
verifyEmailUseCase.execute(token)
.then(() => setStatus('success'))
.catch((e: unknown) => {
const msg = e instanceof Error ? e.message : 'Erro desconhecido.';
setErrorMessage(msg);
setStatus('error');
});
}, [token]);
return (
<Screen>
<View style={styles.content}>
{status === 'loading' && (
<>
<ActivityIndicator size="large" color={colors.accent} />
<Text style={styles.message}>Verificando e-mail</Text>
</>
)}
{status === 'success' && (
<>
<View style={[styles.iconWrapper, styles.iconSuccess]}>
<Ionicons name="checkmark-circle-outline" size={64} color={colors.accent} />
</View>
<Text style={styles.title}>E-mail verificado!</Text>
<Text style={styles.message}>Sua conta está ativa. Faça login para continuar.</Text>
<Button
label="Ir para o login"
onPress={() => router.replace('/(auth)/login')}
style={styles.cta}
/>
</>
)}
{status === 'error' && (
<>
<View style={[styles.iconWrapper, styles.iconError]}>
<Ionicons name="close-circle-outline" size={64} color={colors.error ?? '#FF6B6B'} />
</View>
<Text style={styles.title}>Falha na verificação</Text>
<Text style={styles.message}>{errorMessage}</Text>
<Button
label="Voltar ao login"
onPress={() => router.replace('/(auth)/login')}
style={styles.cta}
/>
</>
)}
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing[4],
paddingHorizontal: spacing[6],
},
iconWrapper: {
width: 100,
height: 100,
borderRadius: radius.xl,
backgroundColor: colors.bgSurface,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing[2],
},
iconSuccess: {},
iconError: {},
title: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.xl,
fontWeight: typography.fontWeight.bold,
color: colors.textPrimary,
textAlign: 'center',
},
message: {
fontFamily: typography.fontFamily.ui,
fontSize: typography.fontSize.base,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.6,
},
cta: { marginTop: spacing[2], width: '100%' },
});
@@ -12,9 +12,8 @@ export class ApiAuthRepository implements AuthRepository {
return this.fetchSession(data.access_token as string, data.refresh_token as string);
}
async register(input: RegisterInput): Promise<AuthSession> {
const { data } = await httpClient.post('/auth/register', input);
return this.fetchSession(data.access_token as string, data.refresh_token as string);
async register(input: RegisterInput): Promise<void> {
await httpClient.post('/auth/register', input);
}
async loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession> {
@@ -36,6 +35,10 @@ export class ApiAuthRepository implements AuthRepository {
});
}
async resendVerification(email: string): Promise<void> {
await httpClient.post('/auth/resend-verification', { email });
}
async forgotPassword(email: string): Promise<void> {
await httpClient.post('/auth/forgot-password', { email });
}
+26 -2
View File
@@ -21,14 +21,14 @@ export class LoginUseCase {
export class RegisterUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(input: RegisterInput): Promise<AuthSession> {
async execute(input: RegisterInput): Promise<void> {
if (!input.email || !input.password) {
throw new Error('E-mail e senha são obrigatórios.');
}
if (input.password.length < 8) {
throw new Error('A senha deve ter ao menos 8 caracteres.');
}
return this.authRepo.register(input);
await this.authRepo.register(input);
}
}
@@ -54,6 +54,18 @@ export class LogoutUseCase {
}
}
/**
* Caso de uso: Reenviar e-mail de verificação de conta.
*/
export class ResendVerificationUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(email: string): Promise<void> {
if (!email) throw new Error('E-mail obrigatório.');
await this.authRepo.resendVerification(email);
}
}
/**
* Caso de uso: Solicitar redefinição de senha.
*/
@@ -65,6 +77,18 @@ export class ForgotPasswordUseCase {
}
}
/**
* Caso de uso: Verificar e-mail com token recebido por deep link.
*/
export class VerifyEmailUseCase {
constructor(private readonly authRepo: AuthRepository) {}
async execute(token: string): Promise<void> {
if (!token) throw new Error('Token inválido.');
await this.authRepo.verifyEmail(token);
}
}
/**
* Caso de uso: Redefinir senha com token.
*/
+4
View File
@@ -8,7 +8,9 @@ import {
LoginUseCase,
RegisterUseCase,
ForgotPasswordUseCase,
ResendVerificationUseCase,
ResetPasswordUseCase,
VerifyEmailUseCase,
LogoutUseCase,
} from '@application/auth/AuthUseCases';
import { CreateFilamentUseCase } from '@application/filament/CreateFilamentUseCase';
@@ -30,8 +32,10 @@ const presetRepository = new ApiSpoolPresetRepository();
// Use cases de autenticação
export const loginUseCase = new LoginUseCase(authRepository);
export const registerUseCase = new RegisterUseCase(authRepository);
export const resendVerificationUseCase = new ResendVerificationUseCase(authRepository);
export const forgotPasswordUseCase = new ForgotPasswordUseCase(authRepository);
export const resetPasswordUseCase = new ResetPasswordUseCase(authRepository);
export const verifyEmailUseCase = new VerifyEmailUseCase(authRepository);
export const logoutUseCase = new LogoutUseCase(authRepository);
// Use cases de filamento
+2 -1
View File
@@ -6,10 +6,11 @@ import type { AuthSession, LoginInput, RegisterInput, GoogleOAuthInput } from '@
*/
export interface AuthRepository {
login(input: LoginInput): Promise<AuthSession>;
register(input: RegisterInput): Promise<AuthSession>;
register(input: RegisterInput): Promise<void>;
loginWithGoogle(input: GoogleOAuthInput): Promise<AuthSession>;
refreshToken(refreshToken: string): Promise<Pick<AuthSession, 'accessToken' | 'refreshToken'>>;
logout(accessToken: string): Promise<void>;
resendVerification(email: string): Promise<void>;
forgotPassword(email: string): Promise<void>;
verifyEmail(token: string): Promise<void>;
resetPassword(token: string, newPassword: string): Promise<void>;