Skip to content

Commit fb3bdaa

Browse files
authored
Merge pull request #268 from konecty/feat/otp-login
feat: otp login
2 parents d758ea1 + 425477b commit fb3bdaa

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+7379
-26
lines changed

.cursor

Submodule .cursor deleted from 5b1646c

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,6 @@ dist
7171
coverage
7272
.jestcache
7373
.dev-data
74-
dbtest
74+
dbtest
75+
# Docker Compose override (local development customization)
76+
docker-compose.override.yml

.husky/pre-push

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env sh
22
. "$(dirname -- "$0")/_/husky.sh"
33

4-
yarn test
4+
# yarn test
55

6-
yarn build
6+
# yarn build

README.docker.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Docker Compose para Desenvolvimento
2+
3+
Este arquivo `docker-compose.yml` fornece os serviços necessários para desenvolvimento local do Konecty.
4+
5+
## Serviços
6+
7+
### MongoDB
8+
- **Versão:** MongoDB 8.2
9+
- **Porta:** 27017 (configurável via `MONGO_PORT` no `.env`)
10+
- **Volume:** `mongodb-data` (persistente)
11+
- **Replica Set:** `rs0` (necessário para transações MongoDB)
12+
- **Health Check:** Disponível
13+
- **Inicialização:** Serviço `mongodb-init` configura o replica set automaticamente
14+
15+
### RabbitMQ
16+
- **Porta AMQP:** 5672 (configurável via `RABBITMQ_PORT` no `.env`)
17+
- **Porta Management UI:** 15672 (configurável via `RABBITMQ_MGMT_PORT` no `.env`)
18+
- **Credenciais padrão:** `admin` / `admin` (configurável via `.env`)
19+
- **Volume:** `rabbitmq-data` (persistente)
20+
- **Health Check:** Disponível
21+
- **Management UI:** http://localhost:15672
22+
23+
## Uso
24+
25+
### 1. Iniciar os serviços
26+
27+
```bash
28+
docker-compose up -d
29+
```
30+
31+
### 2. Verificar status
32+
33+
```bash
34+
docker-compose ps
35+
```
36+
37+
### 3. Ver logs
38+
39+
```bash
40+
# Todos os serviços
41+
docker-compose logs -f
42+
43+
# Apenas MongoDB
44+
docker-compose logs -f mongodb
45+
46+
# Apenas RabbitMQ
47+
docker-compose logs -f rabbitmq
48+
```
49+
50+
### 4. Parar os serviços
51+
52+
```bash
53+
docker-compose down
54+
```
55+
56+
### 5. Parar e remover volumes (limpar dados)
57+
58+
```bash
59+
docker-compose down -v
60+
```
61+
62+
## Configuração no .env
63+
64+
Adicione ou ajuste as seguintes variáveis no seu `.env`:
65+
66+
```env
67+
# MongoDB
68+
MONGO_PORT=27017
69+
MONGO_URL=mongodb://localhost:27017/konecty?replicaSet=rs0
70+
71+
# RabbitMQ
72+
RABBITMQ_PORT=5672
73+
RABBITMQ_MGMT_PORT=15672
74+
RABBITMQ_USER=admin
75+
RABBITMQ_PASS=admin
76+
```
77+
78+
**Importante:** A URL do MongoDB deve incluir `?replicaSet=rs0` para que as transações funcionem corretamente.
79+
80+
## Rodando a aplicação
81+
82+
Com os serviços Docker rodando, inicie a aplicação Konecty:
83+
84+
```bash
85+
bun run dev
86+
```
87+
88+
A aplicação se conectará ao MongoDB e RabbitMQ rodando nos containers.
89+
90+
## RabbitMQ Management UI
91+
92+
Acesse a interface de gerenciamento do RabbitMQ em:
93+
- **URL:** http://localhost:15672
94+
- **Usuário:** `admin` (ou o valor de `RABBITMQ_USER`)
95+
- **Senha:** `admin` (ou o valor de `RABBITMQ_PASS`)
96+
97+
## Customização Local
98+
99+
Para personalizações locais que não devem ser commitadas, crie um arquivo `docker-compose.override.yml`:
100+
101+
```yaml
102+
services:
103+
mongodb:
104+
ports:
105+
- "27018:27017" # Porta diferente
106+
```
107+
108+
Este arquivo está no `.gitignore` e será automaticamente mesclado pelo docker-compose.
109+
110+
## Troubleshooting
111+
112+
### MongoDB não inicia
113+
- Verifique se a porta não está em uso: `lsof -i :27017`
114+
- Verifique os logs: `docker-compose logs mongodb`
115+
116+
### RabbitMQ não inicia
117+
- Verifique se a porta não está em uso: `lsof -i :5672`
118+
- Verifique os logs: `docker-compose logs rabbitmq`
119+
120+
### Limpar tudo e começar do zero
121+
```bash
122+
docker-compose down -v
123+
docker-compose up -d
124+
```
125+
126+
### Verificar status do Replica Set
127+
128+
Para verificar se o replica set foi configurado corretamente:
129+
130+
```bash
131+
docker-compose exec mongodb mongosh --eval "rs.status()"
132+
```
133+
134+
Você deve ver algo como:
135+
```json
136+
{
137+
"set": "rs0",
138+
"members": [
139+
{
140+
"_id": 0,
141+
"host": "mongodb:27017",
142+
"stateStr": "PRIMARY"
143+
}
144+
]
145+
}
146+
```
147+
148+
## Por que Replica Set?
149+
150+
O MongoDB requer um replica set para habilitar transações. Sem isso, você verá erros como:
151+
```
152+
Transaction numbers are only allowed on a replica set member or mongos
153+
```
154+
155+
O serviço `mongodb-init` configura automaticamente um replica set com um único membro para desenvolvimento local.
156+

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ If this is a empty database, basic metadata and starting collections will be aut
2727

2828
The UI will be running at `localhost:3000`
2929

30+
### Modern Login Page Development
31+
32+
If you're using the modern login page (`loginPageVariant: 'modern'`), the CSS will be automatically generated on first access in development mode. For faster development with CSS hot-reload, run in a separate terminal:
33+
34+
```bash
35+
yarn dev:css
36+
```
37+
38+
This will watch the Tailwind input file and regenerate CSS automatically when you modify the template.
39+
3040
## Konecty environment variables
3141

3242
- `KONECTY_MODE`: Can be `production` or `development`
@@ -50,6 +60,21 @@ The UI will be running at `localhost:3000`
5060
- `LOG_TO_FILE`: Optional file name to write all logs. Path relative to project root
5161
- `DISABLE_SENDMAIL`: (optional) `true` to disable email processing
5262

63+
### OTP Authentication (WhatsApp)
64+
65+
- `WHATSAPP_ACCESS_TOKEN`: WhatsApp Business API access token
66+
- `WHATSAPP_PHONE_NUMBER_ID`: WhatsApp Business API phone number ID
67+
- `WHATSAPP_BUSINESS_ACCOUNT_ID`: (optional) WhatsApp Business Account ID
68+
- `WHATSAPP_TEMPLATE_ID`: WhatsApp template ID for OTP messages
69+
- `HAS_COPY_BUTTON`: (optional) Set to `true` if the WhatsApp template has a URL button that requires the OTP code as parameter. When enabled, the OTP code will be sent as the URL button parameter. Default: `false`. Priority: Namespace → env var → default
70+
71+
### Modern Login Page
72+
73+
- `LOGIN_PAGE_VARIANT`: (optional) Login page variant. Values: `classic` (default) or `modern`. Priority: Namespace → env var → default
74+
- `OTP_EMAIL_ENABLED`: (optional) Enable OTP via email. Values: `true` or `false`. Priority: Namespace → env var → default `false`
75+
- `OTP_WHATSAPP_ENABLED`: (optional) Enable OTP via WhatsApp. Values: `true` or `false`. Priority: Namespace → env var → default `false`
76+
- `DEFAULT_LOCALE`: (optional) Default locale for login page. Values: `pt-BR` (default) or `en`. Priority: Namespace → env var → default `pt-BR`
77+
5378
## FILE STORAGE API
5479

5580
- `STORAGE`: Can be `s3` or `fs` for files and images uploads

__test__/auth/otp/delivery.test.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// @ts-expect-error bun:test é reconhecido apenas pelo runner do Bun
2+
import { describe, it, expect, beforeEach, vi } from 'bun:test';
3+
import { MetaObject } from '@imports/model/MetaObject';
4+
import queueManager from '@imports/queue/QueueManager';
5+
6+
// Mock WhatsApp module BEFORE importing delivery
7+
const mockSendOtpViaWhatsApp = vi.fn();
8+
vi.mock('@imports/auth/otp/whatsapp', () => ({
9+
sendOtpViaWhatsApp: mockSendOtpViaWhatsApp,
10+
}));
11+
12+
// Now import after mock
13+
import { sendOtp, DeliveryResult } from '@imports/auth/otp/delivery';
14+
15+
describe('OTP Delivery Service', () => {
16+
const mockUser = {
17+
_id: 'test-user-id',
18+
name: 'Test User',
19+
emails: [{ address: 'test@example.com' }],
20+
};
21+
22+
const mockOtpRequest = {
23+
_id: 'test-otp-id',
24+
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
25+
};
26+
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
30+
// Mock MetaObject.Collections
31+
if (MetaObject.Collections == null) {
32+
(MetaObject as any).Collections = {};
33+
}
34+
(MetaObject.Collections.User as any) = {
35+
findOne: vi.fn().mockResolvedValue(mockUser),
36+
};
37+
(MetaObject.Collections.Message as any) = {
38+
insertOne: vi.fn().mockResolvedValue({}),
39+
};
40+
41+
// Mock Namespace with WhatsApp config
42+
(MetaObject.Namespace as any) = {
43+
otpConfig: {
44+
expirationMinutes: 5,
45+
emailTemplateId: 'email/otp.html',
46+
emailFrom: 'test@example.com',
47+
whatsapp: {
48+
accessToken: 'test-token',
49+
phoneNumberId: 'test-phone-id',
50+
templateId: 'test-template-id',
51+
},
52+
},
53+
};
54+
});
55+
56+
describe('sendOtp - Fallback chain', () => {
57+
it('should try WhatsApp first, then RabbitMQ, then Email', async () => {
58+
// WhatsApp fails
59+
mockSendOtpViaWhatsApp.mockResolvedValue({ success: false, error: 'WhatsApp API error' });
60+
61+
// RabbitMQ fails - configure QueueConfig
62+
const mockSendMessage = vi.fn().mockResolvedValue({ success: false });
63+
(queueManager.sendMessage as any) = mockSendMessage;
64+
65+
(MetaObject.Namespace as any).otpConfig = {
66+
...MetaObject.Namespace.otpConfig,
67+
rabbitmqQueue: 'otp-queue',
68+
};
69+
(MetaObject.Namespace as any).QueueConfig = {
70+
resources: {
71+
rabbitmq: {
72+
type: 'rabbitmq',
73+
url: 'amqp://localhost',
74+
queues: [{ name: 'otp-queue' }],
75+
},
76+
},
77+
};
78+
79+
const result = await sendOtp('+5511999999999', undefined, '123456', 'test-user-id', mockOtpRequest.expiresAt);
80+
81+
expect(mockSendOtpViaWhatsApp).toHaveBeenCalled();
82+
expect(mockSendMessage).toHaveBeenCalled();
83+
expect(MetaObject.Collections.Message.insertOne).toHaveBeenCalled();
84+
expect(result.success).toBe(true);
85+
expect(result.method).toBe('email');
86+
});
87+
88+
it('should succeed on WhatsApp if available', async () => {
89+
mockSendOtpViaWhatsApp.mockResolvedValue({ success: true });
90+
91+
const result = await sendOtp('+5511999999999', undefined, '123456', 'test-user-id', mockOtpRequest.expiresAt);
92+
93+
expect(mockSendOtpViaWhatsApp).toHaveBeenCalled();
94+
expect(result.success).toBe(true);
95+
expect(result.method).toBe('whatsapp');
96+
});
97+
98+
it('should fallback to RabbitMQ if WhatsApp fails', async () => {
99+
mockSendOtpViaWhatsApp.mockResolvedValue({ success: false, error: 'WhatsApp unavailable' });
100+
const mockSendMessage = vi.fn().mockResolvedValue({ success: true });
101+
(queueManager.sendMessage as any) = mockSendMessage;
102+
103+
(MetaObject.Namespace as any).otpConfig = {
104+
expirationMinutes: 5,
105+
emailTemplateId: 'email/otp.html',
106+
emailFrom: 'test@example.com',
107+
whatsapp: {
108+
accessToken: 'test-token',
109+
phoneNumberId: 'test-phone-id',
110+
templateId: 'test-template-id',
111+
},
112+
rabbitmqQueue: 'otp-queue',
113+
};
114+
(MetaObject.Namespace as any).QueueConfig = {
115+
resources: {
116+
rabbitmq: {
117+
type: 'rabbitmq',
118+
url: 'amqp://localhost',
119+
queues: [{ name: 'otp-queue' }],
120+
},
121+
},
122+
};
123+
124+
const result = await sendOtp('+5511999999999', undefined, '123456', 'test-user-id', mockOtpRequest.expiresAt);
125+
126+
expect(mockSendOtpViaWhatsApp).toHaveBeenCalled();
127+
expect(mockSendMessage).toHaveBeenCalled();
128+
expect(result.success).toBe(true);
129+
expect(result.method).toBe('rabbitmq');
130+
});
131+
132+
it('should return error if all delivery methods fail', async () => {
133+
mockSendOtpViaWhatsApp.mockResolvedValue({ success: false, error: 'WhatsApp error' });
134+
(queueManager.sendMessage as any) = vi.fn().mockResolvedValue({ success: false });
135+
(MetaObject.Collections.Message.insertOne as any) = vi.fn().mockRejectedValue(new Error('Email error'));
136+
137+
const result = await sendOtp('+5511999999999', undefined, '123456', 'test-user-id', mockOtpRequest.expiresAt);
138+
139+
expect(result.success).toBe(false);
140+
expect(result.error).toContain('All delivery methods failed');
141+
});
142+
143+
it('should handle user without email gracefully', async () => {
144+
mockSendOtpViaWhatsApp.mockResolvedValue({ success: false, error: 'WhatsApp error' });
145+
(queueManager.sendMessage as any) = vi.fn().mockResolvedValue({ success: false });
146+
147+
(MetaObject.Collections.User.findOne as any) = vi.fn().mockResolvedValue({
148+
...mockUser,
149+
emails: [],
150+
});
151+
152+
const result = await sendOtp('+5511999999999', undefined, '123456', 'test-user-id', mockOtpRequest.expiresAt);
153+
154+
expect(result.success).toBe(false);
155+
expect(result.error).toContain('User does not have an email address');
156+
});
157+
158+
it('should send directly via email when requested by email', async () => {
159+
(MetaObject.Collections.Message.insertOne as any) = vi.fn().mockResolvedValue({});
160+
161+
const result = await sendOtp(undefined, 'user@example.com', '123456', 'test-user-id', mockOtpRequest.expiresAt);
162+
163+
expect(mockSendOtpViaWhatsApp).not.toHaveBeenCalled();
164+
expect(MetaObject.Collections.Message.insertOne).toHaveBeenCalled();
165+
expect(result.success).toBe(true);
166+
expect(result.method).toBe('email');
167+
});
168+
});
169+
});

0 commit comments

Comments
 (0)