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:
Generated
+141
@@ -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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod email_service;
|
||||
|
||||
pub use email_service::EmailService;
|
||||
@@ -8,6 +8,7 @@ mod application;
|
||||
mod config;
|
||||
mod domain;
|
||||
mod error;
|
||||
mod infrastructure;
|
||||
mod ports;
|
||||
mod router;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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` lê `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,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user