ar-saas 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +338 -314
- package/dist/cli.js +19 -0
- package/dist/generator.js +166 -55
- package/package.json +52 -50
- package/templates/backend/.env.example +67 -67
- package/templates/backend/.prettierrc +4 -4
- package/templates/backend/README.md +249 -168
- package/templates/backend/eslint.config.mjs +35 -35
- package/templates/backend/nest-cli.json +8 -8
- package/templates/backend/package-lock.json +10979 -10979
- package/templates/backend/package.json +88 -88
- package/templates/backend/src/app.controller.spec.ts +24 -24
- package/templates/backend/src/app.controller.ts +15 -15
- package/templates/backend/src/app.module.ts +40 -40
- package/templates/backend/src/app.service.ts +11 -11
- package/templates/backend/src/common/base/base.repository.ts +221 -221
- package/templates/backend/src/common/base/base.schema.ts +24 -24
- package/templates/backend/src/common/decorators/cookie.decorator.ts +9 -9
- package/templates/backend/src/common/decorators/current-user.decorator.ts +20 -20
- package/templates/backend/src/common/decorators/workspace-id.decorator.ts +14 -14
- package/templates/backend/src/common/filters/global-exception.filter.ts +61 -61
- package/templates/backend/src/common/guards/jwt-auth.guard.ts +5 -5
- package/templates/backend/src/common/interceptors/workspace-tenant.interceptor.ts +45 -45
- package/templates/backend/src/main.ts +51 -51
- package/templates/backend/src/modules/auth/auth.controller.ts +158 -158
- package/templates/backend/src/modules/auth/auth.module.ts +20 -20
- package/templates/backend/src/modules/auth/auth.service.ts +257 -257
- package/templates/backend/src/modules/auth/dto/forgot-password.dto.ts +9 -9
- package/templates/backend/src/modules/auth/dto/login.dto.ts +14 -14
- package/templates/backend/src/modules/auth/dto/refresh-token.dto.ts +12 -12
- package/templates/backend/src/modules/auth/dto/register.dto.ts +26 -26
- package/templates/backend/src/modules/auth/dto/reset-password.dto.ts +16 -16
- package/templates/backend/src/modules/auth/dto/verify-email.dto.ts +9 -9
- package/templates/backend/src/modules/auth/strategies/jwt.strategy.ts +43 -43
- package/templates/backend/src/modules/mail/mail.module.ts +9 -9
- package/templates/backend/src/modules/mail/mail.service.ts +141 -141
- package/templates/backend/src/modules/users/schemas/user.schema.ts +54 -54
- package/templates/backend/src/modules/users/users.module.ts +14 -14
- package/templates/backend/src/modules/users/users.repository.ts +51 -51
- package/templates/backend/src/modules/users/users.service.ts +104 -104
- package/templates/backend/src/modules/workspaces/schemas/workspace.schema.ts +26 -26
- package/templates/backend/src/modules/workspaces/workspaces.module.ts +16 -16
- package/templates/backend/src/modules/workspaces/workspaces.repository.ts +34 -34
- package/templates/backend/src/modules/workspaces/workspaces.service.ts +42 -42
- package/templates/backend/test/app.e2e-spec.ts +25 -25
- package/templates/backend/test/jest-e2e.json +9 -9
- package/templates/backend/tsconfig.build.json +4 -4
- package/templates/backend/tsconfig.json +26 -26
- package/templates/frontend/.env.local.example +1 -1
- package/templates/frontend/README.md +152 -0
- package/templates/frontend/components.json +20 -20
- package/templates/frontend/eslint.config.mjs +14 -14
- package/templates/frontend/next.config.ts +5 -5
- package/templates/frontend/package-lock.json +6722 -6722
- package/templates/frontend/package.json +48 -48
- package/templates/frontend/pnpm-lock.yaml +5012 -5012
- package/templates/frontend/pnpm-workspace.yaml +3 -3
- package/templates/frontend/postcss.config.mjs +7 -7
- package/templates/frontend/src/app/(auth)/forgot-password/page.tsx +84 -84
- package/templates/frontend/src/app/(auth)/layout.tsx +28 -28
- package/templates/frontend/src/app/(auth)/login/page.tsx +111 -111
- package/templates/frontend/src/app/(auth)/register/page.tsx +161 -161
- package/templates/frontend/src/app/(auth)/reset-password/page.tsx +120 -120
- package/templates/frontend/src/app/(auth)/verify-email/page.tsx +78 -78
- package/templates/frontend/src/app/(dashboard)/billing/page.tsx +111 -111
- package/templates/frontend/src/app/(dashboard)/dashboard/page.tsx +105 -105
- package/templates/frontend/src/app/(dashboard)/layout.tsx +38 -38
- package/templates/frontend/src/app/(dashboard)/profile/page.tsx +226 -226
- package/templates/frontend/src/app/(dashboard)/settings/page.tsx +156 -156
- package/templates/frontend/src/app/(dashboard)/team/page.tsx +178 -178
- package/templates/frontend/src/app/(legal)/privacy/page.tsx +127 -127
- package/templates/frontend/src/app/(legal)/terms/page.tsx +118 -118
- package/templates/frontend/src/app/globals.css +81 -81
- package/templates/frontend/src/app/layout.tsx +26 -26
- package/templates/frontend/src/app/page.tsx +5 -45
- package/templates/frontend/src/app/setup/page.tsx +371 -275
- package/templates/frontend/src/components/dashboard/header.tsx +89 -89
- package/templates/frontend/src/components/dashboard/sidebar.tsx +71 -71
- package/templates/frontend/src/components/dashboard/stat-card.tsx +34 -34
- package/templates/frontend/src/components/landing/faq.tsx +39 -39
- package/templates/frontend/src/components/landing/features.tsx +54 -54
- package/templates/frontend/src/components/landing/footer.tsx +76 -76
- package/templates/frontend/src/components/landing/hero.tsx +72 -72
- package/templates/frontend/src/components/landing/navbar.tsx +78 -78
- package/templates/frontend/src/components/landing/pricing.tsx +90 -90
- package/templates/frontend/src/components/ui/accordion.tsx +52 -52
- package/templates/frontend/src/components/ui/avatar.tsx +46 -46
- package/templates/frontend/src/components/ui/badge.tsx +30 -30
- package/templates/frontend/src/components/ui/button.tsx +52 -52
- package/templates/frontend/src/components/ui/card.tsx +50 -50
- package/templates/frontend/src/components/ui/checkbox.tsx +27 -27
- package/templates/frontend/src/components/ui/dialog.tsx +100 -100
- package/templates/frontend/src/components/ui/dropdown-menu.tsx +173 -173
- package/templates/frontend/src/components/ui/form.tsx +158 -158
- package/templates/frontend/src/components/ui/input.tsx +21 -21
- package/templates/frontend/src/components/ui/label.tsx +22 -22
- package/templates/frontend/src/components/ui/separator.tsx +25 -25
- package/templates/frontend/src/components/ui/skeleton.tsx +7 -7
- package/templates/frontend/src/components/ui/switch.tsx +28 -28
- package/templates/frontend/src/components/ui/tabs.tsx +54 -54
- package/templates/frontend/src/components/ui/textarea.tsx +20 -20
- package/templates/frontend/src/components/ui/toast.tsx +109 -109
- package/templates/frontend/src/components/ui/toaster.tsx +30 -30
- package/templates/frontend/src/config/site.ts +197 -197
- package/templates/frontend/src/hooks/use-toast.ts +116 -116
- package/templates/frontend/src/lib/api/auth.ts +39 -39
- package/templates/frontend/src/lib/api/client.ts +66 -66
- package/templates/frontend/src/lib/hooks/use-auth.ts +1 -1
- package/templates/frontend/src/lib/utils.ts +6 -6
- package/templates/frontend/src/providers/auth-provider.tsx +60 -60
- package/templates/frontend/src/types/api.ts +12 -12
- package/templates/frontend/src/types/auth.ts +27 -27
- package/templates/frontend/tsconfig.json +23 -23
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { Module } from '@nestjs/common';
|
|
2
|
-
import { JwtModule } from '@nestjs/jwt';
|
|
3
|
-
import { PassportModule } from '@nestjs/passport';
|
|
4
|
-
import { UsersModule } from '../users/users.module';
|
|
5
|
-
import { WorkspacesModule } from '../workspaces/workspaces.module';
|
|
6
|
-
import { AuthController } from './auth.controller';
|
|
7
|
-
import { AuthService } from './auth.service';
|
|
8
|
-
import { JwtStrategy } from './strategies/jwt.strategy';
|
|
9
|
-
|
|
10
|
-
@Module({
|
|
11
|
-
imports: [
|
|
12
|
-
PassportModule,
|
|
13
|
-
JwtModule.register({}),
|
|
14
|
-
UsersModule,
|
|
15
|
-
WorkspacesModule,
|
|
16
|
-
],
|
|
17
|
-
controllers: [AuthController],
|
|
18
|
-
providers: [AuthService, JwtStrategy],
|
|
19
|
-
})
|
|
20
|
-
export class AuthModule {}
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { JwtModule } from '@nestjs/jwt';
|
|
3
|
+
import { PassportModule } from '@nestjs/passport';
|
|
4
|
+
import { UsersModule } from '../users/users.module';
|
|
5
|
+
import { WorkspacesModule } from '../workspaces/workspaces.module';
|
|
6
|
+
import { AuthController } from './auth.controller';
|
|
7
|
+
import { AuthService } from './auth.service';
|
|
8
|
+
import { JwtStrategy } from './strategies/jwt.strategy';
|
|
9
|
+
|
|
10
|
+
@Module({
|
|
11
|
+
imports: [
|
|
12
|
+
PassportModule,
|
|
13
|
+
JwtModule.register({}),
|
|
14
|
+
UsersModule,
|
|
15
|
+
WorkspacesModule,
|
|
16
|
+
],
|
|
17
|
+
controllers: [AuthController],
|
|
18
|
+
providers: [AuthService, JwtStrategy],
|
|
19
|
+
})
|
|
20
|
+
export class AuthModule {}
|
|
@@ -1,257 +1,257 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BadRequestException,
|
|
3
|
-
Injectable,
|
|
4
|
-
InternalServerErrorException,
|
|
5
|
-
UnauthorizedException,
|
|
6
|
-
} from '@nestjs/common';
|
|
7
|
-
import { ConfigService } from '@nestjs/config';
|
|
8
|
-
import { JwtService } from '@nestjs/jwt';
|
|
9
|
-
import * as crypto from 'crypto';
|
|
10
|
-
import * as bcrypt from 'bcryptjs';
|
|
11
|
-
import type { StringValue } from 'ms';
|
|
12
|
-
import { MailService } from '../mail/mail.service';
|
|
13
|
-
import { UsersService } from '../users/users.service';
|
|
14
|
-
import { UserDocument } from '../users/schemas/user.schema';
|
|
15
|
-
import { WorkspacesService } from '../workspaces/workspaces.service';
|
|
16
|
-
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
|
17
|
-
import { LoginDto } from './dto/login.dto';
|
|
18
|
-
import { RegisterDto } from './dto/register.dto';
|
|
19
|
-
import { ResetPasswordDto } from './dto/reset-password.dto';
|
|
20
|
-
|
|
21
|
-
interface TokenPair {
|
|
22
|
-
accessToken: string;
|
|
23
|
-
refreshToken: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
@Injectable()
|
|
27
|
-
export class AuthService {
|
|
28
|
-
constructor(
|
|
29
|
-
private readonly usersService: UsersService,
|
|
30
|
-
private readonly workspacesService: WorkspacesService,
|
|
31
|
-
private readonly mailService: MailService,
|
|
32
|
-
private readonly jwtService: JwtService,
|
|
33
|
-
private readonly configService: ConfigService,
|
|
34
|
-
) {}
|
|
35
|
-
|
|
36
|
-
// ─── Register ──────────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
async register(dto: RegisterDto): Promise<{ message: string }> {
|
|
39
|
-
// 1. Create workspace with placeholder ownerId (user doesn't exist yet)
|
|
40
|
-
const workspace = await this.workspacesService.create('pending', dto.name);
|
|
41
|
-
const workspaceId = String(workspace._id);
|
|
42
|
-
|
|
43
|
-
// 2. Create user linked to the workspace
|
|
44
|
-
const user = await this.usersService.create({
|
|
45
|
-
name: dto.name,
|
|
46
|
-
email: dto.email,
|
|
47
|
-
password: dto.password,
|
|
48
|
-
workspaceId,
|
|
49
|
-
});
|
|
50
|
-
const userId = String(user._id);
|
|
51
|
-
|
|
52
|
-
// 3. Set the real ownerId on the workspace
|
|
53
|
-
await this.workspacesService.update(workspaceId, { ownerId: userId });
|
|
54
|
-
|
|
55
|
-
// 4. Generate email verification token (expires in 24h)
|
|
56
|
-
const token = this.generateSecureToken();
|
|
57
|
-
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
58
|
-
await this.usersService.setEmailVerificationToken(userId, token, expiresAt);
|
|
59
|
-
|
|
60
|
-
// 5. Send verification email
|
|
61
|
-
await this.mailService.sendVerificationEmail(dto.email, dto.name, token);
|
|
62
|
-
|
|
63
|
-
return { message: 'Verification email sent' };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ─── Verify Email ───────────────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
async verifyEmail(token: string): Promise<{ message: string }> {
|
|
69
|
-
const user = await this.usersService.findByVerificationToken(token);
|
|
70
|
-
if (!user) {
|
|
71
|
-
throw new BadRequestException('Token inválido o expirado.');
|
|
72
|
-
}
|
|
73
|
-
if (
|
|
74
|
-
user.emailVerificationTokenExpiresAt &&
|
|
75
|
-
user.emailVerificationTokenExpiresAt < new Date()
|
|
76
|
-
) {
|
|
77
|
-
throw new BadRequestException(
|
|
78
|
-
'El enlace de verificación expiró. Solicitá uno nuevo.',
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
await this.usersService.markEmailVerified(String(user._id));
|
|
82
|
-
return { message: 'Email verified' };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ─── Login ──────────────────────────────────────────────────────────────────
|
|
86
|
-
|
|
87
|
-
async login(
|
|
88
|
-
dto: LoginDto,
|
|
89
|
-
): Promise<{ user: UserDocument } & TokenPair> {
|
|
90
|
-
const user = await this.usersService.findByEmail(dto.email);
|
|
91
|
-
if (!user?.password) {
|
|
92
|
-
throw new UnauthorizedException('Email o contraseña incorrectos.');
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const valid = await bcrypt.compare(dto.password, user.password);
|
|
96
|
-
if (!valid) {
|
|
97
|
-
throw new UnauthorizedException('Email o contraseña incorrectos.');
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (!user.emailVerified) {
|
|
101
|
-
throw new UnauthorizedException(
|
|
102
|
-
'Por favor verificá tu email antes de ingresar.',
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const userId = String(user._id);
|
|
107
|
-
const tokens = await this.generateTokens(
|
|
108
|
-
userId,
|
|
109
|
-
user.email,
|
|
110
|
-
user.workspaceId,
|
|
111
|
-
user.role,
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
await Promise.all([
|
|
115
|
-
this.usersService.updateRefreshToken(userId, tokens.refreshToken),
|
|
116
|
-
this.usersService.updateLastLoginAt(userId),
|
|
117
|
-
]);
|
|
118
|
-
|
|
119
|
-
// Re-fetch without secrets for the response
|
|
120
|
-
const cleanUser = await this.usersService.findById(userId);
|
|
121
|
-
if (!cleanUser) {
|
|
122
|
-
throw new InternalServerErrorException('Error al obtener el usuario.');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return { user: cleanUser, ...tokens };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ─── Refresh Token ──────────────────────────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
async refresh(token: string): Promise<TokenPair> {
|
|
131
|
-
let payload: { sub: string };
|
|
132
|
-
try {
|
|
133
|
-
payload = await this.jwtService.verifyAsync<{ sub: string }>(token, {
|
|
134
|
-
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
|
135
|
-
});
|
|
136
|
-
} catch {
|
|
137
|
-
throw new UnauthorizedException('Refresh token inválido.');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const user = await this.usersService.validateRefreshToken(
|
|
141
|
-
payload.sub,
|
|
142
|
-
token,
|
|
143
|
-
);
|
|
144
|
-
if (!user) {
|
|
145
|
-
throw new UnauthorizedException('Refresh token inválido.');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const userId = String(user._id);
|
|
149
|
-
const tokens = await this.generateTokens(
|
|
150
|
-
userId,
|
|
151
|
-
user.email,
|
|
152
|
-
user.workspaceId,
|
|
153
|
-
user.role,
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
await this.usersService.updateRefreshToken(userId, tokens.refreshToken);
|
|
157
|
-
return tokens;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ─── Logout ─────────────────────────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
async logout(userId: string): Promise<void> {
|
|
163
|
-
await this.usersService.updateRefreshToken(userId, null);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ─── Forgot Password ────────────────────────────────────────────────────────
|
|
167
|
-
|
|
168
|
-
async forgotPassword(
|
|
169
|
-
dto: ForgotPasswordDto,
|
|
170
|
-
): Promise<{ message: string }> {
|
|
171
|
-
const user = await this.usersService.findByEmail(dto.email);
|
|
172
|
-
|
|
173
|
-
// Always return the same response to avoid leaking email existence
|
|
174
|
-
if (user?.emailVerified) {
|
|
175
|
-
const token = this.generateSecureToken();
|
|
176
|
-
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
|
177
|
-
await this.usersService.setPasswordResetToken(
|
|
178
|
-
String(user._id),
|
|
179
|
-
token,
|
|
180
|
-
expiresAt,
|
|
181
|
-
);
|
|
182
|
-
await this.mailService.sendPasswordResetEmail(
|
|
183
|
-
dto.email,
|
|
184
|
-
user.name,
|
|
185
|
-
token,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return { message: 'Reset email sent' };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ─── Reset Password ─────────────────────────────────────────────────────────
|
|
193
|
-
|
|
194
|
-
async resetPassword(dto: ResetPasswordDto): Promise<{ message: string }> {
|
|
195
|
-
const user = await this.usersService.findByPasswordResetToken(dto.token);
|
|
196
|
-
if (!user) {
|
|
197
|
-
throw new BadRequestException('Token inválido o expirado.');
|
|
198
|
-
}
|
|
199
|
-
if (
|
|
200
|
-
user.passwordResetTokenExpiresAt &&
|
|
201
|
-
user.passwordResetTokenExpiresAt < new Date()
|
|
202
|
-
) {
|
|
203
|
-
throw new BadRequestException(
|
|
204
|
-
'El enlace de restablecimiento expiró. Solicitá uno nuevo.',
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
await this.usersService.updatePassword(String(user._id), dto.newPassword);
|
|
209
|
-
return { message: 'Password reset successful' };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ─── Get Me ─────────────────────────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
async getMe(userId: string): Promise<UserDocument> {
|
|
215
|
-
const user = await this.usersService.findById(userId);
|
|
216
|
-
if (!user) {
|
|
217
|
-
throw new UnauthorizedException('Usuario no encontrado.');
|
|
218
|
-
}
|
|
219
|
-
return user;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ─── Private helpers ────────────────────────────────────────────────────────
|
|
223
|
-
|
|
224
|
-
private generateSecureToken(): string {
|
|
225
|
-
return crypto.randomBytes(32).toString('hex');
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
private async generateTokens(
|
|
229
|
-
userId: string,
|
|
230
|
-
email: string,
|
|
231
|
-
workspaceId: string,
|
|
232
|
-
role: string,
|
|
233
|
-
): Promise<TokenPair> {
|
|
234
|
-
const accessPayload = { sub: userId, email, workspaceId, role };
|
|
235
|
-
const refreshPayload = { sub: userId };
|
|
236
|
-
|
|
237
|
-
const accessExpiresIn = (
|
|
238
|
-
this.configService.get<string>('JWT_ACCESS_EXPIRES_IN') ?? '15m'
|
|
239
|
-
) as StringValue;
|
|
240
|
-
const refreshExpiresIn = (
|
|
241
|
-
this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') ?? '7d'
|
|
242
|
-
) as StringValue;
|
|
243
|
-
|
|
244
|
-
const [accessToken, refreshToken] = await Promise.all([
|
|
245
|
-
this.jwtService.signAsync(accessPayload, {
|
|
246
|
-
secret: this.configService.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
|
247
|
-
expiresIn: accessExpiresIn,
|
|
248
|
-
}),
|
|
249
|
-
this.jwtService.signAsync(refreshPayload, {
|
|
250
|
-
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
|
251
|
-
expiresIn: refreshExpiresIn,
|
|
252
|
-
}),
|
|
253
|
-
]);
|
|
254
|
-
|
|
255
|
-
return { accessToken, refreshToken };
|
|
256
|
-
}
|
|
257
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
Injectable,
|
|
4
|
+
InternalServerErrorException,
|
|
5
|
+
UnauthorizedException,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { ConfigService } from '@nestjs/config';
|
|
8
|
+
import { JwtService } from '@nestjs/jwt';
|
|
9
|
+
import * as crypto from 'crypto';
|
|
10
|
+
import * as bcrypt from 'bcryptjs';
|
|
11
|
+
import type { StringValue } from 'ms';
|
|
12
|
+
import { MailService } from '../mail/mail.service';
|
|
13
|
+
import { UsersService } from '../users/users.service';
|
|
14
|
+
import { UserDocument } from '../users/schemas/user.schema';
|
|
15
|
+
import { WorkspacesService } from '../workspaces/workspaces.service';
|
|
16
|
+
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
|
17
|
+
import { LoginDto } from './dto/login.dto';
|
|
18
|
+
import { RegisterDto } from './dto/register.dto';
|
|
19
|
+
import { ResetPasswordDto } from './dto/reset-password.dto';
|
|
20
|
+
|
|
21
|
+
interface TokenPair {
|
|
22
|
+
accessToken: string;
|
|
23
|
+
refreshToken: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Injectable()
|
|
27
|
+
export class AuthService {
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly usersService: UsersService,
|
|
30
|
+
private readonly workspacesService: WorkspacesService,
|
|
31
|
+
private readonly mailService: MailService,
|
|
32
|
+
private readonly jwtService: JwtService,
|
|
33
|
+
private readonly configService: ConfigService,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
// ─── Register ──────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
async register(dto: RegisterDto): Promise<{ message: string }> {
|
|
39
|
+
// 1. Create workspace with placeholder ownerId (user doesn't exist yet)
|
|
40
|
+
const workspace = await this.workspacesService.create('pending', dto.name);
|
|
41
|
+
const workspaceId = String(workspace._id);
|
|
42
|
+
|
|
43
|
+
// 2. Create user linked to the workspace
|
|
44
|
+
const user = await this.usersService.create({
|
|
45
|
+
name: dto.name,
|
|
46
|
+
email: dto.email,
|
|
47
|
+
password: dto.password,
|
|
48
|
+
workspaceId,
|
|
49
|
+
});
|
|
50
|
+
const userId = String(user._id);
|
|
51
|
+
|
|
52
|
+
// 3. Set the real ownerId on the workspace
|
|
53
|
+
await this.workspacesService.update(workspaceId, { ownerId: userId });
|
|
54
|
+
|
|
55
|
+
// 4. Generate email verification token (expires in 24h)
|
|
56
|
+
const token = this.generateSecureToken();
|
|
57
|
+
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
58
|
+
await this.usersService.setEmailVerificationToken(userId, token, expiresAt);
|
|
59
|
+
|
|
60
|
+
// 5. Send verification email
|
|
61
|
+
await this.mailService.sendVerificationEmail(dto.email, dto.name, token);
|
|
62
|
+
|
|
63
|
+
return { message: 'Verification email sent' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Verify Email ───────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async verifyEmail(token: string): Promise<{ message: string }> {
|
|
69
|
+
const user = await this.usersService.findByVerificationToken(token);
|
|
70
|
+
if (!user) {
|
|
71
|
+
throw new BadRequestException('Token inválido o expirado.');
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
user.emailVerificationTokenExpiresAt &&
|
|
75
|
+
user.emailVerificationTokenExpiresAt < new Date()
|
|
76
|
+
) {
|
|
77
|
+
throw new BadRequestException(
|
|
78
|
+
'El enlace de verificación expiró. Solicitá uno nuevo.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
await this.usersService.markEmailVerified(String(user._id));
|
|
82
|
+
return { message: 'Email verified' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Login ──────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async login(
|
|
88
|
+
dto: LoginDto,
|
|
89
|
+
): Promise<{ user: UserDocument } & TokenPair> {
|
|
90
|
+
const user = await this.usersService.findByEmail(dto.email);
|
|
91
|
+
if (!user?.password) {
|
|
92
|
+
throw new UnauthorizedException('Email o contraseña incorrectos.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const valid = await bcrypt.compare(dto.password, user.password);
|
|
96
|
+
if (!valid) {
|
|
97
|
+
throw new UnauthorizedException('Email o contraseña incorrectos.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!user.emailVerified) {
|
|
101
|
+
throw new UnauthorizedException(
|
|
102
|
+
'Por favor verificá tu email antes de ingresar.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const userId = String(user._id);
|
|
107
|
+
const tokens = await this.generateTokens(
|
|
108
|
+
userId,
|
|
109
|
+
user.email,
|
|
110
|
+
user.workspaceId,
|
|
111
|
+
user.role,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await Promise.all([
|
|
115
|
+
this.usersService.updateRefreshToken(userId, tokens.refreshToken),
|
|
116
|
+
this.usersService.updateLastLoginAt(userId),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
// Re-fetch without secrets for the response
|
|
120
|
+
const cleanUser = await this.usersService.findById(userId);
|
|
121
|
+
if (!cleanUser) {
|
|
122
|
+
throw new InternalServerErrorException('Error al obtener el usuario.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { user: cleanUser, ...tokens };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Refresh Token ──────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
async refresh(token: string): Promise<TokenPair> {
|
|
131
|
+
let payload: { sub: string };
|
|
132
|
+
try {
|
|
133
|
+
payload = await this.jwtService.verifyAsync<{ sub: string }>(token, {
|
|
134
|
+
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
throw new UnauthorizedException('Refresh token inválido.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const user = await this.usersService.validateRefreshToken(
|
|
141
|
+
payload.sub,
|
|
142
|
+
token,
|
|
143
|
+
);
|
|
144
|
+
if (!user) {
|
|
145
|
+
throw new UnauthorizedException('Refresh token inválido.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const userId = String(user._id);
|
|
149
|
+
const tokens = await this.generateTokens(
|
|
150
|
+
userId,
|
|
151
|
+
user.email,
|
|
152
|
+
user.workspaceId,
|
|
153
|
+
user.role,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await this.usersService.updateRefreshToken(userId, tokens.refreshToken);
|
|
157
|
+
return tokens;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Logout ─────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async logout(userId: string): Promise<void> {
|
|
163
|
+
await this.usersService.updateRefreshToken(userId, null);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Forgot Password ────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async forgotPassword(
|
|
169
|
+
dto: ForgotPasswordDto,
|
|
170
|
+
): Promise<{ message: string }> {
|
|
171
|
+
const user = await this.usersService.findByEmail(dto.email);
|
|
172
|
+
|
|
173
|
+
// Always return the same response to avoid leaking email existence
|
|
174
|
+
if (user?.emailVerified) {
|
|
175
|
+
const token = this.generateSecureToken();
|
|
176
|
+
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
|
177
|
+
await this.usersService.setPasswordResetToken(
|
|
178
|
+
String(user._id),
|
|
179
|
+
token,
|
|
180
|
+
expiresAt,
|
|
181
|
+
);
|
|
182
|
+
await this.mailService.sendPasswordResetEmail(
|
|
183
|
+
dto.email,
|
|
184
|
+
user.name,
|
|
185
|
+
token,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { message: 'Reset email sent' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Reset Password ─────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
async resetPassword(dto: ResetPasswordDto): Promise<{ message: string }> {
|
|
195
|
+
const user = await this.usersService.findByPasswordResetToken(dto.token);
|
|
196
|
+
if (!user) {
|
|
197
|
+
throw new BadRequestException('Token inválido o expirado.');
|
|
198
|
+
}
|
|
199
|
+
if (
|
|
200
|
+
user.passwordResetTokenExpiresAt &&
|
|
201
|
+
user.passwordResetTokenExpiresAt < new Date()
|
|
202
|
+
) {
|
|
203
|
+
throw new BadRequestException(
|
|
204
|
+
'El enlace de restablecimiento expiró. Solicitá uno nuevo.',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
await this.usersService.updatePassword(String(user._id), dto.newPassword);
|
|
209
|
+
return { message: 'Password reset successful' };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Get Me ─────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
async getMe(userId: string): Promise<UserDocument> {
|
|
215
|
+
const user = await this.usersService.findById(userId);
|
|
216
|
+
if (!user) {
|
|
217
|
+
throw new UnauthorizedException('Usuario no encontrado.');
|
|
218
|
+
}
|
|
219
|
+
return user;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Private helpers ────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
private generateSecureToken(): string {
|
|
225
|
+
return crypto.randomBytes(32).toString('hex');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async generateTokens(
|
|
229
|
+
userId: string,
|
|
230
|
+
email: string,
|
|
231
|
+
workspaceId: string,
|
|
232
|
+
role: string,
|
|
233
|
+
): Promise<TokenPair> {
|
|
234
|
+
const accessPayload = { sub: userId, email, workspaceId, role };
|
|
235
|
+
const refreshPayload = { sub: userId };
|
|
236
|
+
|
|
237
|
+
const accessExpiresIn = (
|
|
238
|
+
this.configService.get<string>('JWT_ACCESS_EXPIRES_IN') ?? '15m'
|
|
239
|
+
) as StringValue;
|
|
240
|
+
const refreshExpiresIn = (
|
|
241
|
+
this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') ?? '7d'
|
|
242
|
+
) as StringValue;
|
|
243
|
+
|
|
244
|
+
const [accessToken, refreshToken] = await Promise.all([
|
|
245
|
+
this.jwtService.signAsync(accessPayload, {
|
|
246
|
+
secret: this.configService.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
|
247
|
+
expiresIn: accessExpiresIn,
|
|
248
|
+
}),
|
|
249
|
+
this.jwtService.signAsync(refreshPayload, {
|
|
250
|
+
secret: this.configService.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
|
251
|
+
expiresIn: refreshExpiresIn,
|
|
252
|
+
}),
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
return { accessToken, refreshToken };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
-
import { IsEmail, IsNotEmpty } from 'class-validator';
|
|
3
|
-
|
|
4
|
-
export class ForgotPasswordDto {
|
|
5
|
-
@ApiProperty({ example: 'juan@example.com' })
|
|
6
|
-
@IsEmail({}, { message: 'El email no tiene un formato válido' })
|
|
7
|
-
@IsNotEmpty({ message: 'El email es obligatorio' })
|
|
8
|
-
email!: string;
|
|
9
|
-
}
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { IsEmail, IsNotEmpty } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class ForgotPasswordDto {
|
|
5
|
+
@ApiProperty({ example: 'juan@example.com' })
|
|
6
|
+
@IsEmail({}, { message: 'El email no tiene un formato válido' })
|
|
7
|
+
@IsNotEmpty({ message: 'El email es obligatorio' })
|
|
8
|
+
email!: string;
|
|
9
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
-
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
|
3
|
-
|
|
4
|
-
export class LoginDto {
|
|
5
|
-
@ApiProperty({ example: 'juan@example.com' })
|
|
6
|
-
@IsEmail({}, { message: 'El email no tiene un formato válido' })
|
|
7
|
-
@IsNotEmpty({ message: 'El email es obligatorio' })
|
|
8
|
-
email!: string;
|
|
9
|
-
|
|
10
|
-
@ApiProperty()
|
|
11
|
-
@IsString({ message: 'La contraseña debe ser texto' })
|
|
12
|
-
@IsNotEmpty({ message: 'La contraseña es obligatoria' })
|
|
13
|
-
password!: string;
|
|
14
|
-
}
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class LoginDto {
|
|
5
|
+
@ApiProperty({ example: 'juan@example.com' })
|
|
6
|
+
@IsEmail({}, { message: 'El email no tiene un formato válido' })
|
|
7
|
+
@IsNotEmpty({ message: 'El email es obligatorio' })
|
|
8
|
+
email!: string;
|
|
9
|
+
|
|
10
|
+
@ApiProperty()
|
|
11
|
+
@IsString({ message: 'La contraseña debe ser texto' })
|
|
12
|
+
@IsNotEmpty({ message: 'La contraseña es obligatoria' })
|
|
13
|
+
password!: string;
|
|
14
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
-
import { IsOptional, IsString } from 'class-validator';
|
|
3
|
-
|
|
4
|
-
export class RefreshTokenDto {
|
|
5
|
-
@ApiPropertyOptional({
|
|
6
|
-
description:
|
|
7
|
-
'Refresh token (opcional — se lee de la cookie refresh_token si no se provee)',
|
|
8
|
-
})
|
|
9
|
-
@IsString({ message: 'El token debe ser texto' })
|
|
10
|
-
@IsOptional()
|
|
11
|
-
refreshToken?: string;
|
|
12
|
-
}
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { IsOptional, IsString } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class RefreshTokenDto {
|
|
5
|
+
@ApiPropertyOptional({
|
|
6
|
+
description:
|
|
7
|
+
'Refresh token (opcional — se lee de la cookie refresh_token si no se provee)',
|
|
8
|
+
})
|
|
9
|
+
@IsString({ message: 'El token debe ser texto' })
|
|
10
|
+
@IsOptional()
|
|
11
|
+
refreshToken?: string;
|
|
12
|
+
}
|