Phase1
This commit is contained in:
@@ -12,7 +12,9 @@
|
||||
"test": "node --test test/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.4",
|
||||
"@hexahost/auth": "workspace:*",
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
@@ -21,12 +23,15 @@
|
||||
"@nestjs/platform-fastify": "^11.0.7",
|
||||
"@nestjs/swagger": "^11.0.3",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"bullmq": "^5.34.8",
|
||||
"fastify": "^5.2.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"nestjs-pino": "^4.3.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.4.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AppConfigModule } from './config/app-config.module';
|
||||
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -32,7 +34,9 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
},
|
||||
]),
|
||||
PrismaModule,
|
||||
AppConfigModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
31
apps/api/src/auth/audit.service.ts
Normal file
31
apps/api/src/auth/audit.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface AuditEventInput {
|
||||
action: string;
|
||||
userId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async record(input: AuditEventInput): Promise<void> {
|
||||
await this.prisma.auditEvent.create({
|
||||
data: {
|
||||
action: input.action,
|
||||
userId: input.userId,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
metadata: input.metadata as Prisma.InputJsonValue | undefined,
|
||||
ipAddress: input.ipAddress,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
203
apps/api/src/auth/auth.controller.ts
Normal file
203
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import {
|
||||
forgotPasswordRequestSchema,
|
||||
loginRequestSchema,
|
||||
registerRequestSchema,
|
||||
resetPasswordRequestSchema,
|
||||
twoFactorConfirmRequestSchema,
|
||||
twoFactorDisableRequestSchema,
|
||||
twoFactorVerifyRequestSchema,
|
||||
verifyEmailRequestSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser, Public } from './decorators/current-user.decorator';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@SkipThrottle()
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Register a new account' })
|
||||
@ApiResponse({ status: 201, description: 'Account created' })
|
||||
register(
|
||||
@Body(new ZodValidationPipe(registerRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.register(
|
||||
body as Parameters<AuthService['register']>[0],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
login(
|
||||
@Body(new ZodValidationPipe(loginRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
return this.authService.login(
|
||||
body as Parameters<AuthService['login']>[0],
|
||||
reply,
|
||||
request.ip,
|
||||
request.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Logout current session' })
|
||||
logout(
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
const token = this.sessionService.readSessionToken(request);
|
||||
return this.authService.logout(token, reply, request.ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('verify-email')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verify email address' })
|
||||
verifyEmail(
|
||||
@Body(new ZodValidationPipe(verifyEmailRequestSchema)) body: unknown,
|
||||
@Query('token') queryToken: string | undefined,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
const payload = body as { token?: string };
|
||||
const token = payload.token ?? queryToken;
|
||||
|
||||
if (!token) {
|
||||
throw new BadRequestException('Verification token is required');
|
||||
}
|
||||
|
||||
return this.authService.verifyEmail({ token }, request.ip);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('resend-verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Resend email verification' })
|
||||
resendVerification(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.resendVerification(user.id, request.ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Request password reset email' })
|
||||
forgotPassword(
|
||||
@Body(new ZodValidationPipe(forgotPasswordRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.forgotPassword(
|
||||
body as Parameters<AuthService['forgotPassword']>[0],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Reset password with token' })
|
||||
resetPassword(
|
||||
@Body(new ZodValidationPipe(resetPasswordRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.resetPassword(
|
||||
body as Parameters<AuthService['resetPassword']>[0],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('2fa/setup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Begin two-factor setup' })
|
||||
setupTwoFactor(@CurrentUser() user: { id: string }) {
|
||||
return this.authService.setupTwoFactor(user.id);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('2fa/confirm')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Confirm two-factor setup' })
|
||||
confirmTwoFactor(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body(new ZodValidationPipe(twoFactorConfirmRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.confirmTwoFactor(
|
||||
user.id,
|
||||
body as Parameters<AuthService['confirmTwoFactor']>[1],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('2fa/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Complete login with two-factor code' })
|
||||
verifyTwoFactor(
|
||||
@Body(new ZodValidationPipe(twoFactorVerifyRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res({ passthrough: true }) reply: FastifyReply,
|
||||
) {
|
||||
return this.authService.verifyTwoFactorLogin(
|
||||
body as Parameters<AuthService['verifyTwoFactorLogin']>[0],
|
||||
reply,
|
||||
request.ip,
|
||||
request.headers['user-agent'],
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Post('2fa/disable')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Disable two-factor authentication' })
|
||||
disableTwoFactor(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Body(new ZodValidationPipe(twoFactorDisableRequestSchema)) body: unknown,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.authService.disableTwoFactor(
|
||||
user.id,
|
||||
body as Parameters<AuthService['disableTwoFactor']>[1],
|
||||
request.ip,
|
||||
);
|
||||
}
|
||||
}
|
||||
52
apps/api/src/auth/auth.module.ts
Normal file
52
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import {
|
||||
AuthService,
|
||||
NOTIFICATIONS_QUEUE,
|
||||
REDIS_CLIENT,
|
||||
} from './auth.service';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { MeController } from './me.controller';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule],
|
||||
controllers: [AuthController, MeController],
|
||||
providers: [
|
||||
AuthService,
|
||||
SessionService,
|
||||
AuditService,
|
||||
SessionGuard,
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATIONS_QUEUE,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Queue('notifications', {
|
||||
connection: {
|
||||
url: config.REDIS_URL,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AuthService, SessionService, SessionGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
711
apps/api/src/auth/auth.service.ts
Normal file
711
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,711 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type Redis from 'ioredis';
|
||||
|
||||
import {
|
||||
buildOtpAuthUrl,
|
||||
generateRecoveryCodes,
|
||||
generateSecureToken,
|
||||
generateTotpSecret,
|
||||
generateUsernameFromEmail,
|
||||
hashPassword,
|
||||
hashRecoveryCode,
|
||||
hashToken,
|
||||
validateUsername,
|
||||
verifyPassword,
|
||||
verifyRecoveryCode,
|
||||
verifyTotpCode,
|
||||
} from '@hexahost/auth';
|
||||
import type {
|
||||
AuthUser,
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
MessageResponse,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
TwoFactorConfirmRequest,
|
||||
TwoFactorDisableRequest,
|
||||
TwoFactorVerifyRequest,
|
||||
VerifyEmailRequest,
|
||||
} from '@hexahost/contracts';
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
|
||||
import { APP_CONFIG } from '../config/config.constants';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
const LOCKOUT_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
||||
const LOGIN_CHALLENGE_TTL_SECONDS = 300;
|
||||
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
|
||||
export const NOTIFICATIONS_QUEUE = Symbol('NOTIFICATIONS_QUEUE');
|
||||
|
||||
interface LoginChallengePayload {
|
||||
userId: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
interface NotificationEmailPayload {
|
||||
type: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
template: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async register(
|
||||
input: RegisterRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('Email is already registered');
|
||||
}
|
||||
|
||||
let username = input.username?.trim().toLowerCase();
|
||||
|
||||
if (username) {
|
||||
const validation = validateUsername(username);
|
||||
if (!validation.valid) {
|
||||
throw new BadRequestException(validation.error);
|
||||
}
|
||||
} else {
|
||||
username = generateUsernameFromEmail(email);
|
||||
}
|
||||
|
||||
username = await this.ensureUniqueUsername(username);
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
displayName: input.displayName?.trim() ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userCredential.create({
|
||||
data: {
|
||||
userId: created.id,
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.createEmailVerificationToken(user.id, user.email);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.register',
|
||||
userId: user.id,
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Registration successful. Please verify your email.' };
|
||||
}
|
||||
|
||||
async login(
|
||||
input: LoginRequest,
|
||||
reply: FastifyReply,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: {
|
||||
credentials: { take: 1, orderBy: { createdAt: 'desc' } },
|
||||
totpCredential: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || user.credentials.length === 0) {
|
||||
await this.auditService.record({
|
||||
action: 'auth.login_failed',
|
||||
metadata: { email, reason: 'unknown_user' },
|
||||
ipAddress,
|
||||
});
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
if (user.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('Account is not active');
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
||||
throw new ForbiddenException('Account is temporarily locked');
|
||||
}
|
||||
|
||||
const credential = user.credentials[0]!;
|
||||
const passwordValid = await verifyPassword(input.password, credential.passwordHash);
|
||||
|
||||
if (!passwordValid) {
|
||||
const failedAttempts = user.failedLoginAttempts + 1;
|
||||
const lockedUntil =
|
||||
failedAttempts >= LOCKOUT_ATTEMPTS
|
||||
? new Date(Date.now() + LOCKOUT_DURATION_MS)
|
||||
: null;
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: failedAttempts,
|
||||
lockedUntil,
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.login_failed',
|
||||
userId: user.id,
|
||||
metadata: {
|
||||
reason: 'invalid_password',
|
||||
failedAttempts,
|
||||
locked: lockedUntil !== null,
|
||||
},
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
if (lockedUntil) {
|
||||
throw new ForbiddenException(
|
||||
'Account locked due to too many failed login attempts',
|
||||
);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid email or password');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.totpCredential?.enabledAt) {
|
||||
const challengeId = this.sessionService.createLoginChallengeId();
|
||||
const payload: LoginChallengePayload = { userId: user.id, ipAddress, userAgent };
|
||||
|
||||
await this.redis.set(
|
||||
this.challengeKey(challengeId),
|
||||
JSON.stringify(payload),
|
||||
'EX',
|
||||
LOGIN_CHALLENGE_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return {
|
||||
twoFactorRequired: true as const,
|
||||
challengeId,
|
||||
};
|
||||
}
|
||||
|
||||
return this.completeLogin(user.id, reply, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
async verifyTwoFactorLogin(
|
||||
input: TwoFactorVerifyRequest,
|
||||
reply: FastifyReply,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const raw = await this.redis.get(this.challengeKey(input.challengeId));
|
||||
|
||||
if (!raw) {
|
||||
throw new UnauthorizedException('Invalid or expired login challenge');
|
||||
}
|
||||
|
||||
const challenge = JSON.parse(raw) as LoginChallengePayload;
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: challenge.userId },
|
||||
include: { totpCredential: { include: { recoveryCodes: true } } },
|
||||
});
|
||||
|
||||
if (!user?.totpCredential?.enabledAt) {
|
||||
throw new UnauthorizedException('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const code = input.code.replace(/\s+/gu, '');
|
||||
let verified = verifyTotpCode(user.totpCredential.secret, code);
|
||||
|
||||
if (!verified) {
|
||||
verified = await this.tryRecoveryCode(user.totpCredential.id, code);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
throw new UnauthorizedException('Invalid two-factor code');
|
||||
}
|
||||
|
||||
await this.redis.del(this.challengeKey(input.challengeId));
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.2fa_verified',
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return this.completeLogin(
|
||||
user.id,
|
||||
reply,
|
||||
ipAddress ?? challenge.ipAddress,
|
||||
userAgent ?? challenge.userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
async logout(
|
||||
token: string | undefined,
|
||||
reply: FastifyReply,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
if (token) {
|
||||
const userId = await this.sessionService.revokeByToken(token);
|
||||
|
||||
if (userId) {
|
||||
await this.auditService.record({
|
||||
action: 'auth.logout',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.sessionService.clearSessionCookie(reply);
|
||||
return { message: 'Logged out' };
|
||||
}
|
||||
|
||||
async verifyEmail(
|
||||
input: VerifyEmailRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const tokenHash = hashToken(input.token);
|
||||
const record = await this.prisma.emailVerificationToken.findUnique({
|
||||
where: { tokenHash },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!record || record.usedAt || record.expiresAt <= new Date()) {
|
||||
throw new BadRequestException('Invalid or expired verification token');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: record.userId },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
}),
|
||||
this.prisma.emailVerificationToken.update({
|
||||
where: { id: record.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.email_verified',
|
||||
userId: record.userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Email verified successfully' };
|
||||
}
|
||||
|
||||
async resendVerification(userId: string, ipAddress?: string): Promise<MessageResponse> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (user.emailVerifiedAt) {
|
||||
return { message: 'Email is already verified' };
|
||||
}
|
||||
|
||||
await this.createEmailVerificationToken(user.id, user.email);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.verification_resent',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Verification email sent' };
|
||||
}
|
||||
|
||||
async forgotPassword(
|
||||
input: ForgotPasswordRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (user) {
|
||||
const token = generateSecureToken();
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
await this.prisma.passwordResetToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt: new Date(Date.now() + PASSWORD_RESET_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
await this.enqueueEmail({
|
||||
type: 'password_reset',
|
||||
to: user.email,
|
||||
subject: 'Reset your HexaHost password',
|
||||
template: 'password-reset',
|
||||
data: {
|
||||
resetUrl: `${this.config.APP_URL}/reset-password?token=${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.password_reset_requested',
|
||||
userId: user.id,
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
'If an account exists for this email, a password reset link has been sent.',
|
||||
};
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
input: ResetPasswordRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const tokenHash = hashToken(input.token);
|
||||
const record = await this.prisma.passwordResetToken.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!record || record.usedAt || record.expiresAt <= new Date()) {
|
||||
throw new BadRequestException('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(input.newPassword);
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.userCredential.updateMany({
|
||||
where: { userId: record.userId },
|
||||
data: { passwordHash },
|
||||
}),
|
||||
this.prisma.passwordResetToken.update({
|
||||
where: { id: record.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
this.prisma.userSession.updateMany({
|
||||
where: { userId: record.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}),
|
||||
this.prisma.user.update({
|
||||
where: { id: record.userId },
|
||||
data: { failedLoginAttempts: 0, lockedUntil: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.password_reset_completed',
|
||||
userId: record.userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async setupTwoFactor(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
|
||||
await this.prisma.totpCredential.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
secret,
|
||||
enabledAt: null,
|
||||
},
|
||||
update: {
|
||||
secret,
|
||||
enabledAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.recoveryCode.deleteMany({
|
||||
where: {
|
||||
totpCredential: { userId },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
secret,
|
||||
qrUrl: buildOtpAuthUrl(this.config.APP_NAME, user.email, secret),
|
||||
};
|
||||
}
|
||||
|
||||
async confirmTwoFactor(
|
||||
userId: string,
|
||||
input: TwoFactorConfirmRequest,
|
||||
ipAddress?: string,
|
||||
) {
|
||||
const credential = await this.prisma.totpCredential.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new BadRequestException('Two-factor setup has not been started');
|
||||
}
|
||||
|
||||
if (!verifyTotpCode(credential.secret, input.code)) {
|
||||
throw new BadRequestException('Invalid authenticator code');
|
||||
}
|
||||
|
||||
const recoveryCodes = generateRecoveryCodes();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.totpCredential.update({
|
||||
where: { id: credential.id },
|
||||
data: { enabledAt: new Date() },
|
||||
});
|
||||
|
||||
await tx.recoveryCode.deleteMany({
|
||||
where: { totpCredentialId: credential.id },
|
||||
});
|
||||
|
||||
await tx.recoveryCode.createMany({
|
||||
data: recoveryCodes.map((code) => ({
|
||||
totpCredentialId: credential.id,
|
||||
codeHash: hashRecoveryCode(code),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.2fa_enabled',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Two-factor authentication enabled',
|
||||
recoveryCodes,
|
||||
};
|
||||
}
|
||||
|
||||
async disableTwoFactor(
|
||||
userId: string,
|
||||
input: TwoFactorDisableRequest,
|
||||
ipAddress?: string,
|
||||
): Promise<MessageResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
credentials: { take: 1, orderBy: { createdAt: 'desc' } },
|
||||
totpCredential: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.totpCredential?.enabledAt) {
|
||||
throw new BadRequestException('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const credential = user.credentials[0];
|
||||
if (!credential || !(await verifyPassword(input.password, credential.passwordHash))) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
const code = input.code.replace(/\s+/gu, '');
|
||||
let verified = verifyTotpCode(user.totpCredential.secret, code);
|
||||
|
||||
if (!verified) {
|
||||
verified = await this.tryRecoveryCode(user.totpCredential.id, code);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
throw new UnauthorizedException('Invalid two-factor code');
|
||||
}
|
||||
|
||||
await this.prisma.totpCredential.delete({
|
||||
where: { id: user.totpCredential.id },
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.2fa_disabled',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { message: 'Two-factor authentication disabled' };
|
||||
}
|
||||
|
||||
toAuthUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
emailVerifiedAt: Date | null;
|
||||
platformRoles?: Array<{ role: { name: string } }>;
|
||||
}): AuthUser {
|
||||
const authenticated = this.sessionService.toAuthenticatedUser({
|
||||
...user,
|
||||
platformRoles: user.platformRoles ?? [],
|
||||
});
|
||||
|
||||
return {
|
||||
id: authenticated.id,
|
||||
email: authenticated.email,
|
||||
username: authenticated.username,
|
||||
displayName: authenticated.displayName,
|
||||
emailVerified: authenticated.emailVerified,
|
||||
roles: authenticated.roles,
|
||||
};
|
||||
}
|
||||
|
||||
private async completeLogin(
|
||||
userId: string,
|
||||
reply: FastifyReply,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const { token, expiresAt } = await this.sessionService.createSession(
|
||||
userId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
this.sessionService.setSessionCookie(reply, token, expiresAt);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
platformRoles: { include: { role: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'auth.login',
|
||||
userId,
|
||||
ipAddress,
|
||||
});
|
||||
|
||||
return { user: this.toAuthUser(user) };
|
||||
}
|
||||
|
||||
private async ensureUniqueUsername(baseUsername: string): Promise<string> {
|
||||
let candidate = baseUsername;
|
||||
let suffix = 0;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { username: candidate },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
const suffixText = String(suffix);
|
||||
candidate = `${baseUsername.slice(0, Math.max(1, 32 - suffixText.length))}${suffixText}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async createEmailVerificationToken(userId: string, email: string): Promise<void> {
|
||||
const token = generateSecureToken();
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
await this.prisma.emailVerificationToken.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt: new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
await this.enqueueEmail({
|
||||
type: 'email_verification',
|
||||
to: email,
|
||||
subject: 'Verify your HexaHost email',
|
||||
template: 'email-verification',
|
||||
data: {
|
||||
verifyUrl: `${this.config.APP_URL}/verify-email?token=${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async enqueueEmail(payload: NotificationEmailPayload): Promise<void> {
|
||||
await this.notificationsQueue.add('send-email', payload, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 100,
|
||||
});
|
||||
}
|
||||
|
||||
private challengeKey(challengeId: string): string {
|
||||
return `hgc:login-challenge:${challengeId}`;
|
||||
}
|
||||
|
||||
private async tryRecoveryCode(
|
||||
totpCredentialId: string,
|
||||
code: string,
|
||||
): Promise<boolean> {
|
||||
const recoveryCodes = await this.prisma.recoveryCode.findMany({
|
||||
where: {
|
||||
totpCredentialId,
|
||||
usedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
for (const recoveryCode of recoveryCodes) {
|
||||
if (verifyRecoveryCode(code, recoveryCode.codeHash)) {
|
||||
await this.prisma.recoveryCode.update({
|
||||
where: { id: recoveryCode.id },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
37
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
37
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import type { AuthenticatedUser, SessionContext } from '@hexahost/auth';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
export interface RequestWithSession extends FastifyRequest {
|
||||
session?: SessionContext;
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
if (!request.user) {
|
||||
throw new Error('CurrentUser decorator requires SessionGuard');
|
||||
}
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
|
||||
export const CurrentSession = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): SessionContext => {
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
if (!request.session) {
|
||||
throw new Error('CurrentSession decorator requires SessionGuard');
|
||||
}
|
||||
return request.session;
|
||||
},
|
||||
);
|
||||
10
apps/api/src/auth/dto/index.ts
Normal file
10
apps/api/src/auth/dto/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
registerRequestSchema,
|
||||
loginRequestSchema,
|
||||
verifyEmailRequestSchema,
|
||||
forgotPasswordRequestSchema,
|
||||
resetPasswordRequestSchema,
|
||||
twoFactorConfirmRequestSchema,
|
||||
twoFactorVerifyRequestSchema,
|
||||
twoFactorDisableRequestSchema,
|
||||
} from '@hexahost/contracts';
|
||||
48
apps/api/src/auth/guards/session.guard.ts
Normal file
48
apps/api/src/auth/guards/session.guard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
import {
|
||||
IS_PUBLIC_KEY,
|
||||
type RequestWithSession,
|
||||
} from '../decorators/current-user.decorator';
|
||||
import { SessionService } from '../session.service';
|
||||
|
||||
@Injectable()
|
||||
export class SessionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithSession>();
|
||||
const token = this.sessionService.readSessionToken(
|
||||
request as FastifyRequest,
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
const session = await this.sessionService.validateSessionToken(token);
|
||||
request.session = session;
|
||||
request.user = session.user;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
55
apps/api/src/auth/me.controller.ts
Normal file
55
apps/api/src/auth/me.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
CurrentSession,
|
||||
CurrentUser,
|
||||
} from './decorators/current-user.decorator';
|
||||
import { SessionGuard } from './guards/session.guard';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@ApiTags('me')
|
||||
@Controller('me')
|
||||
@UseGuards(SessionGuard)
|
||||
export class MeController {
|
||||
constructor(private readonly sessionService: SessionService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiResponse({ status: 200, description: 'Current user' })
|
||||
getMe(@CurrentUser() user: { id: string }) {
|
||||
return { user };
|
||||
}
|
||||
|
||||
@Get('sessions')
|
||||
@ApiOperation({ summary: 'List active sessions' })
|
||||
async listSessions(
|
||||
@CurrentUser() user: { id: string },
|
||||
@CurrentSession() session: { sessionId: string },
|
||||
) {
|
||||
const sessions = await this.sessionService.listSessions(
|
||||
user.id,
|
||||
session.sessionId,
|
||||
);
|
||||
return { sessions };
|
||||
}
|
||||
|
||||
@Delete('sessions/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Revoke a session' })
|
||||
async revokeSession(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) sessionId: string,
|
||||
): Promise<void> {
|
||||
await this.sessionService.revokeSession(sessionId, user.id);
|
||||
}
|
||||
}
|
||||
195
apps/api/src/auth/session.service.ts
Normal file
195
apps/api/src/auth/session.service.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import {
|
||||
SESSION_COOKIE_NAME,
|
||||
UserRole,
|
||||
createSessionExpiry,
|
||||
generateSecureToken,
|
||||
hashToken,
|
||||
type AuthenticatedUser,
|
||||
type SessionContext,
|
||||
} from '@hexahost/auth';
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
|
||||
import { APP_CONFIG } from '../config/config.constants';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async createSession(
|
||||
userId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string,
|
||||
): Promise<{ token: string; sessionId: string; expiresAt: Date }> {
|
||||
const token = generateSecureToken();
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = createSessionExpiry();
|
||||
|
||||
const session = await this.prisma.userSession.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, sessionId: session.id, expiresAt };
|
||||
}
|
||||
|
||||
setSessionCookie(reply: FastifyReply, token: string, expiresAt: Date): void {
|
||||
const isProduction = this.config.APP_ENV === 'production';
|
||||
|
||||
void reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
clearSessionCookie(reply: FastifyReply): void {
|
||||
void reply.clearCookie(SESSION_COOKIE_NAME, {
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
readSessionToken(request: FastifyRequest): string | undefined {
|
||||
const cookies = request.cookies as Record<string, string | undefined>;
|
||||
const token = cookies[SESSION_COOKIE_NAME];
|
||||
return token && token.length > 0 ? token : undefined;
|
||||
}
|
||||
|
||||
async validateSessionToken(token: string): Promise<SessionContext> {
|
||||
const tokenHash = hashToken(token);
|
||||
const now = new Date();
|
||||
|
||||
const session = await this.prisma.userSession.findUnique({
|
||||
where: { tokenHash },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
platformRoles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!session ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt <= now ||
|
||||
session.user.status !== 'ACTIVE'
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid or expired session');
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
issuedAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
user: this.toAuthenticatedUser(session.user),
|
||||
};
|
||||
}
|
||||
|
||||
async revokeSession(sessionId: string, userId: string): Promise<void> {
|
||||
const session = await this.prisma.userSession.findFirst({
|
||||
where: { id: sessionId, userId },
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.userSession.update({
|
||||
where: { id: sessionId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeByToken(token: string): Promise<string | undefined> {
|
||||
const tokenHash = hashToken(token);
|
||||
const session = await this.prisma.userSession.findUnique({
|
||||
where: { tokenHash },
|
||||
});
|
||||
|
||||
if (!session || session.revokedAt) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await this.prisma.userSession.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
return session.userId;
|
||||
}
|
||||
|
||||
async listSessions(userId: string, currentSessionId: string) {
|
||||
const sessions = await this.prisma.userSession.findMany({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
current: session.id === currentSessionId,
|
||||
}));
|
||||
}
|
||||
|
||||
createLoginChallengeId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
toAuthenticatedUser(
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
emailVerifiedAt: Date | null;
|
||||
platformRoles: Array<{ role: { name: string } }>;
|
||||
},
|
||||
): AuthenticatedUser {
|
||||
const roles = user.platformRoles
|
||||
.map((entry) => entry.role.name)
|
||||
.filter((name): name is UserRole =>
|
||||
Object.values(UserRole).includes(name as UserRole),
|
||||
);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
roles: roles.length > 0 ? roles : [UserRole.USER],
|
||||
};
|
||||
}
|
||||
}
|
||||
22
apps/api/src/common/pipes/zod-validation.pipe.ts
Normal file
22
apps/api/src/common/pipes/zod-validation.pipe.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
type PipeTransform,
|
||||
} from '@nestjs/common';
|
||||
import type { ZodSchema } from 'zod';
|
||||
|
||||
export class ZodValidationPipe<T> implements PipeTransform {
|
||||
constructor(private readonly schema: ZodSchema<T>) {}
|
||||
|
||||
transform(value: unknown): T {
|
||||
const result = this.schema.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
const messages = result.error.issues.map(
|
||||
(issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`,
|
||||
);
|
||||
throw new BadRequestException(messages);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
16
apps/api/src/config/app-config.module.ts
Normal file
16
apps/api/src/config/app-config.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { APP_CONFIG } from './config.constants';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useFactory: () => getConfig(),
|
||||
},
|
||||
],
|
||||
exports: [APP_CONFIG],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
5
apps/api/src/config/config.constants.ts
Normal file
5
apps/api/src/config/config.constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AppConfig } from '@hexahost/config';
|
||||
|
||||
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||
|
||||
export type { AppConfig };
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import cookie from '@fastify/cookie';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
@@ -30,8 +31,16 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
app.useLogger(app.get(Logger));
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableCors({
|
||||
origin: config.APP_URL,
|
||||
credentials: true,
|
||||
});
|
||||
app.useGlobalFilters(new Rfc7807ExceptionFilter());
|
||||
|
||||
await app.register(cookie, {
|
||||
secret: config.SESSION_SECRET,
|
||||
});
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle(config.APP_NAME)
|
||||
.setDescription('HexaHost GameCloud REST API')
|
||||
|
||||
76
apps/api/test/auth.test.js
Normal file
76
apps/api/test/auth.test.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('api auth contract', () => {
|
||||
it('returns ok for liveness-style health response', async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/api/v1/health/live') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
service: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const addr = server.address();
|
||||
assert.ok(addr && typeof addr === 'object');
|
||||
const port = addr.port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/v1/health/live`);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.status, 'ok');
|
||||
assert.equal(body.service, 'api');
|
||||
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
});
|
||||
|
||||
it('documents auth module routes in source', () => {
|
||||
const authControllerPath = join(
|
||||
process.cwd(),
|
||||
'src',
|
||||
'auth',
|
||||
'auth.controller.ts',
|
||||
);
|
||||
const meControllerPath = join(process.cwd(), 'src', 'auth', 'me.controller.ts');
|
||||
const authSource = readFileSync(authControllerPath, 'utf8');
|
||||
const meSource = readFileSync(meControllerPath, 'utf8');
|
||||
const combined = `${authSource}\n${meSource}`;
|
||||
|
||||
const expectedSnippets = [
|
||||
"@Post('register')",
|
||||
"@Post('login')",
|
||||
"@Post('logout')",
|
||||
"@Post('verify-email')",
|
||||
"@Post('resend-verification')",
|
||||
"@Post('forgot-password')",
|
||||
"@Post('reset-password')",
|
||||
"@Post('2fa/setup')",
|
||||
"@Post('2fa/confirm')",
|
||||
"@Post('2fa/verify')",
|
||||
"@Post('2fa/disable')",
|
||||
"@Get()",
|
||||
"@Get('sessions')",
|
||||
"@Delete('sessions/:id')",
|
||||
"@Controller('auth')",
|
||||
"@Controller('me')",
|
||||
];
|
||||
|
||||
for (const snippet of expectedSnippets) {
|
||||
assert.ok(combined.includes(snippet), `missing ${snippet}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -44,12 +44,51 @@
|
||||
"registerSubtitle": "Registrieren Sie sich für die Plattform.",
|
||||
"email": "E-Mail",
|
||||
"emailPlaceholder": "name@beispiel.de",
|
||||
"username": "Benutzername",
|
||||
"usernamePlaceholder": "spieler123",
|
||||
"displayName": "Anzeigename",
|
||||
"displayNamePlaceholder": "Ihr Name",
|
||||
"optional": "optional",
|
||||
"password": "Passwort",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"noAccount": "Noch kein Konto?",
|
||||
"hasAccount": "Bereits registriert?",
|
||||
"apiPending": "Authentifizierung wird in Phase 1 angebunden. Formularstruktur ist vorbereitet."
|
||||
"forgotPasswordLink": "Passwort vergessen?",
|
||||
"forgotPasswordTitle": "Passwort zurücksetzen",
|
||||
"forgotPasswordSubtitle": "Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Link zum Zurücksetzen.",
|
||||
"forgotPasswordSubmit": "Link senden",
|
||||
"forgotPasswordSent": "Falls ein Konto mit dieser E-Mail existiert, erhalten Sie in Kürze einen Link.",
|
||||
"resetPasswordTitle": "Neues Passwort festlegen",
|
||||
"resetPasswordSubtitle": "Wählen Sie ein neues Passwort für Ihr Konto.",
|
||||
"resetPasswordSubmit": "Passwort aktualisieren",
|
||||
"resetPasswordMissingToken": "Dieser Link ist ungültig oder abgelaufen.",
|
||||
"verifyEmailTitle": "E-Mail-Verifizierung",
|
||||
"verifyEmailSent": "Registrierung erfolgreich. Bitte prüfen Sie Ihren Posteingang und klicken Sie auf den Verifizierungslink.",
|
||||
"verifyEmailLoading": "E-Mail-Adresse wird verifiziert…",
|
||||
"verifyEmailSuccess": "Ihre E-Mail-Adresse wurde verifiziert. Sie können sich jetzt anmelden.",
|
||||
"verifyEmailError": "Verifizierung fehlgeschlagen. Der Link ist möglicherweise ungültig oder abgelaufen.",
|
||||
"verifyEmailMissingToken": "Kein Verifizierungstoken angegeben.",
|
||||
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
|
||||
"twoFactorSubtitle": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.",
|
||||
"twoFactorCode": "Authentifizierungscode",
|
||||
"twoFactorSubmit": "Bestätigen",
|
||||
"twoFactorMissingChallenge": "Keine Anmelde-Challenge gefunden. Bitte melden Sie sich erneut an.",
|
||||
"twoFactorSetupTitle": "Zwei-Faktor-Authentifizierung",
|
||||
"twoFactorSetupDescription": "Schützen Sie Ihr Konto mit zusätzlicher TOTP-Sicherheit.",
|
||||
"twoFactorSetupStart": "2FA einrichten",
|
||||
"twoFactorScanQr": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App und geben Sie den Code ein.",
|
||||
"twoFactorQrAlt": "QR-Code für Authenticator-Einrichtung",
|
||||
"twoFactorConfirm": "2FA aktivieren",
|
||||
"twoFactorEnabled": "Zwei-Faktor-Authentifizierung ist für Ihr Konto aktiviert.",
|
||||
"recoveryCodesTitle": "Wiederherstellungscodes — sicher aufbewahren",
|
||||
"securityTitle": "Sicherheit",
|
||||
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"sessionNone": "Sitzung: keine",
|
||||
"errors": {
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -61,8 +100,7 @@
|
||||
"status": "Status",
|
||||
"resources": "Ressourcen",
|
||||
"activity": "Aktivität",
|
||||
"activityEmpty": "Keine aktuellen Ereignisse",
|
||||
"protectedNote": "Geschützter Bereich — Authentifizierung wird in Phase 1 aktiviert."
|
||||
"activityEmpty": "Keine aktuellen Ereignisse"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
@@ -70,6 +108,9 @@
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"passwordMin": "Passwort muss mindestens 8 Zeichen haben",
|
||||
"confirmPasswordRequired": "Passwortbestätigung ist erforderlich",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein"
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"usernameInvalid": "Benutzername: 3–32 Zeichen (Buchstaben, Zahlen, _ und -)",
|
||||
"codeRequired": "Authentifizierungscode ist erforderlich",
|
||||
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,12 +44,51 @@
|
||||
"registerSubtitle": "Register for the platform.",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "name@example.com",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "player123",
|
||||
"displayName": "Display name",
|
||||
"displayNamePlaceholder": "Your name",
|
||||
"optional": "optional",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"confirmPassword": "Confirm password",
|
||||
"noAccount": "No account yet?",
|
||||
"hasAccount": "Already registered?",
|
||||
"apiPending": "Authentication will be connected in Phase 1. Form structure is ready."
|
||||
"forgotPasswordLink": "Forgot password?",
|
||||
"forgotPasswordTitle": "Reset password",
|
||||
"forgotPasswordSubtitle": "Enter your email address and we will send you a reset link.",
|
||||
"forgotPasswordSubmit": "Send reset link",
|
||||
"forgotPasswordSent": "If an account exists for this email, you will receive a reset link shortly.",
|
||||
"resetPasswordTitle": "Set new password",
|
||||
"resetPasswordSubtitle": "Choose a new password for your account.",
|
||||
"resetPasswordSubmit": "Update password",
|
||||
"resetPasswordMissingToken": "This reset link is invalid or has expired.",
|
||||
"verifyEmailTitle": "Email verification",
|
||||
"verifyEmailSent": "Registration successful. Please check your inbox and click the verification link.",
|
||||
"verifyEmailLoading": "Verifying your email address…",
|
||||
"verifyEmailSuccess": "Your email address has been verified. You can now sign in.",
|
||||
"verifyEmailError": "Verification failed. The link may be invalid or expired.",
|
||||
"verifyEmailMissingToken": "No verification token was provided.",
|
||||
"twoFactorTitle": "Two-factor authentication",
|
||||
"twoFactorSubtitle": "Enter the 6-digit code from your authenticator app.",
|
||||
"twoFactorCode": "Authentication code",
|
||||
"twoFactorSubmit": "Verify",
|
||||
"twoFactorMissingChallenge": "No login challenge found. Please sign in again.",
|
||||
"twoFactorSetupTitle": "Two-factor authentication",
|
||||
"twoFactorSetupDescription": "Add an extra layer of security to your account with TOTP.",
|
||||
"twoFactorSetupStart": "Set up 2FA",
|
||||
"twoFactorScanQr": "Scan this QR code with your authenticator app, then enter the generated code.",
|
||||
"twoFactorQrAlt": "QR code for authenticator setup",
|
||||
"twoFactorConfirm": "Enable 2FA",
|
||||
"twoFactorEnabled": "Two-factor authentication is enabled on your account.",
|
||||
"recoveryCodesTitle": "Recovery codes — save these in a secure place",
|
||||
"securityTitle": "Security",
|
||||
"securitySubtitle": "Manage authentication and security settings for your account.",
|
||||
"backToLogin": "Back to sign in",
|
||||
"sessionNone": "session: none",
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -61,8 +100,7 @@
|
||||
"status": "Status",
|
||||
"resources": "Resources",
|
||||
"activity": "Activity",
|
||||
"activityEmpty": "No recent events",
|
||||
"protectedNote": "Protected area — authentication will be enabled in Phase 1."
|
||||
"activityEmpty": "No recent events"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email is required",
|
||||
@@ -70,6 +108,9 @@
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordMin": "Password must be at least 8 characters",
|
||||
"confirmPasswordRequired": "Password confirmation is required",
|
||||
"passwordMismatch": "Passwords do not match"
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"usernameInvalid": "Username must be 3–32 characters (letters, numbers, _ and -)",
|
||||
"codeRequired": "Authentication code is required",
|
||||
"codeInvalid": "Enter a valid 6-digit code"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -31,9 +32,7 @@ export default async function DashboardLayout({
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
auth: pending · session: none
|
||||
</p>
|
||||
<DashboardUserInfo />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
20
apps/web/src/app/[locale]/forgot-password/page.tsx
Normal file
20
apps/web/src/app/[locale]/forgot-password/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
|
||||
|
||||
interface ForgotPasswordPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function ForgotPasswordPage({ params }: ForgotPasswordPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<ForgotPasswordForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { SiteLayout } from "@/components/layout/site-layout";
|
||||
import { AuthProvider } from "@/components/providers/auth-provider";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { routing, type Locale } from "@/i18n/routing";
|
||||
@@ -30,7 +31,9 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<SiteLayout>{children}</SiteLayout>
|
||||
<AuthProvider>
|
||||
<SiteLayout>{children}</SiteLayout>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
20
apps/web/src/app/[locale]/login/2fa/page.tsx
Normal file
20
apps/web/src/app/[locale]/login/2fa/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { Login2FaForm } from "@/components/auth/login-2fa-form";
|
||||
|
||||
interface Login2FaPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function Login2FaPage({ params }: Login2FaPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<Login2FaForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
|
||||
@@ -11,7 +12,9 @@ export default async function LoginPage({ params }: LoginPageProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<LoginForm />
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
20
apps/web/src/app/[locale]/reset-password/page.tsx
Normal file
20
apps/web/src/app/[locale]/reset-password/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ResetPasswordForm } from "@/components/auth/reset-password-form";
|
||||
|
||||
interface ResetPasswordPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function ResetPasswordPage({ params }: ResetPasswordPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/app/[locale]/security/page.tsx
Normal file
17
apps/web/src/app/[locale]/security/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { SecurityContent } from "@/components/auth/security-content";
|
||||
|
||||
interface SecurityPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function SecurityPage({ params }: SecurityPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<SecurityContent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/app/[locale]/verify-email/page.tsx
Normal file
20
apps/web/src/app/[locale]/verify-email/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Suspense } from "react";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { VerifyEmailContent } from "@/components/auth/verify-email-content";
|
||||
|
||||
interface VerifyEmailPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function VerifyEmailPage({ params }: VerifyEmailPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
|
||||
<Suspense>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/auth/forgot-password-form.tsx
Normal file
114
apps/web/src/components/auth/forgot-password-form.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ApiError, forgotPassword } from "@/lib/api/auth";
|
||||
import { createForgotPasswordSchema, type ForgotPasswordFormValues } from "@/lib/schemas/auth";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const schema = createForgotPasswordSchema({
|
||||
emailRequired: tv("emailRequired"),
|
||||
emailInvalid: tv("emailInvalid"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ForgotPasswordFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: ForgotPasswordFormValues) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await forgotPassword(values.email);
|
||||
setSubmitted(true);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("forgotPasswordTitle")}</CardTitle>
|
||||
<CardDescription>{t("forgotPasswordSent")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("forgotPasswordTitle")}</CardTitle>
|
||||
<CardDescription>{t("forgotPasswordSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<p id="email-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isSubmitting}>
|
||||
{t("forgotPasswordSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
123
apps/web/src/components/auth/login-2fa-form.tsx
Normal file
123
apps/web/src/components/auth/login-2fa-form.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, verifyTwoFactorLogin } from "@/lib/api/auth";
|
||||
import { createTwoFactorSchema, type TwoFactorFormValues } from "@/lib/schemas/auth";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function Login2FaForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const challengeId = searchParams.get("challenge");
|
||||
|
||||
const schema = createTwoFactorSchema({
|
||||
codeRequired: tv("codeRequired"),
|
||||
codeInvalid: tv("codeInvalid"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TwoFactorFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { code: "" },
|
||||
});
|
||||
|
||||
if (!challengeId) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("twoFactorTitle")}</CardTitle>
|
||||
<CardDescription>{t("twoFactorMissingChallenge")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit(values: TwoFactorFormValues) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await verifyTwoFactorLogin(challengeId!, values.code);
|
||||
await refresh();
|
||||
router.push("/dashboard");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("twoFactorTitle")}</CardTitle>
|
||||
<CardDescription>{t("twoFactorSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="code" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("twoFactorCode")}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
aria-invalid={errors.code ? "true" : "false"}
|
||||
aria-describedby={errors.code ? "code-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("code")}
|
||||
/>
|
||||
{errors.code ? (
|
||||
<p id="code-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.code.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isSubmitting}>
|
||||
{t("twoFactorSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { submitLogin } from "@/lib/api/auth";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, submitLogin } from "@/lib/api/auth";
|
||||
import { createLoginSchema, type LoginFormValues } from "@/lib/schemas/auth";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function LoginForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const schema = createLoginSchema({
|
||||
emailRequired: tv("emailRequired"),
|
||||
@@ -31,8 +39,26 @@ export function LoginForm() {
|
||||
});
|
||||
|
||||
async function onSubmit(values: LoginFormValues) {
|
||||
await submitLogin(values);
|
||||
setSubmitted(true);
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
const result = await submitLogin(values);
|
||||
|
||||
if (result.twoFactorRequired && result.challengeId) {
|
||||
router.push(`/login/2fa?challenge=${encodeURIComponent(result.challengeId)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await refresh();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
router.push(redirectTo?.startsWith("/") ? redirectTo : "/dashboard");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -54,7 +80,7 @@ export function LoginForm() {
|
||||
placeholder={t("emailPlaceholder")}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email ? (
|
||||
@@ -65,9 +91,17 @@ export function LoginForm() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
</label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{t("forgotPasswordLink")}
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -75,7 +109,7 @@ export function LoginForm() {
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
aria-describedby={errors.password ? "password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password ? (
|
||||
@@ -85,9 +119,9 @@ export function LoginForm() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
{t("apiPending")}
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -5,14 +5,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { submitRegister } from "@/lib/api/auth";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, submitRegister } from "@/lib/api/auth";
|
||||
import { createRegisterSchema, type RegisterFormValues } from "@/lib/schemas/auth";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function RegisterForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const router = useRouter();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const schema = createRegisterSchema({
|
||||
emailRequired: tv("emailRequired"),
|
||||
@@ -21,6 +25,7 @@ export function RegisterForm() {
|
||||
passwordMin: tv("passwordMin"),
|
||||
confirmPasswordRequired: tv("confirmPasswordRequired"),
|
||||
passwordMismatch: tv("passwordMismatch"),
|
||||
usernameInvalid: tv("usernameInvalid"),
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -29,12 +34,27 @@ export function RegisterForm() {
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { email: "", password: "", confirmPassword: "" },
|
||||
defaultValues: { email: "", username: "", displayName: "", password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: RegisterFormValues) {
|
||||
await submitRegister({ email: values.email, password: values.password });
|
||||
setSubmitted(true);
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await submitRegister({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
...(values.username ? { username: values.username } : {}),
|
||||
...(values.displayName ? { displayName: values.displayName } : {}),
|
||||
});
|
||||
router.push("/verify-email?sent=1");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -56,7 +76,7 @@ export function RegisterForm() {
|
||||
placeholder={t("emailPlaceholder")}
|
||||
aria-invalid={errors.email ? "true" : "false"}
|
||||
aria-describedby={errors.email ? "email-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email ? (
|
||||
@@ -66,6 +86,44 @@ export function RegisterForm() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("username")}{" "}
|
||||
<span className="font-normal text-zinc-500">({t("optional")})</span>
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
aria-invalid={errors.username ? "true" : "false"}
|
||||
aria-describedby={errors.username ? "username-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("username")}
|
||||
/>
|
||||
{errors.username ? (
|
||||
<p id="username-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="displayName" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("displayName")}{" "}
|
||||
<span className="font-normal text-zinc-500">({t("optional")})</span>
|
||||
</label>
|
||||
<input
|
||||
id="displayName"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder={t("displayNamePlaceholder")}
|
||||
aria-invalid={errors.displayName ? "true" : "false"}
|
||||
className={inputClassName}
|
||||
{...register("displayName")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
@@ -77,7 +135,7 @@ export function RegisterForm() {
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
aria-describedby={errors.password ? "password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password ? (
|
||||
@@ -101,7 +159,7 @@ export function RegisterForm() {
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.confirmPassword ? "true" : "false"}
|
||||
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
|
||||
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={inputClassName}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
{errors.confirmPassword ? (
|
||||
@@ -111,9 +169,9 @@ export function RegisterForm() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{submitted ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
{t("apiPending")}
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
138
apps/web/src/components/auth/reset-password-form.tsx
Normal file
138
apps/web/src/components/auth/reset-password-form.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError, resetPassword } from "@/lib/api/auth";
|
||||
import { createResetPasswordSchema, type ResetPasswordFormValues } from "@/lib/schemas/auth";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function ResetPasswordForm() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const schema = createResetPasswordSchema({
|
||||
passwordRequired: tv("passwordRequired"),
|
||||
passwordMin: tv("passwordMin"),
|
||||
confirmPasswordRequired: tv("confirmPasswordRequired"),
|
||||
passwordMismatch: tv("passwordMismatch"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ResetPasswordFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("resetPasswordTitle")}</CardTitle>
|
||||
<CardDescription>{t("resetPasswordMissingToken")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/forgot-password" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("forgotPasswordLink")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit(values: ResetPasswordFormValues) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await resetPassword(token!, values.password);
|
||||
router.push("/login");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("resetPasswordTitle")}</CardTitle>
|
||||
<CardDescription>{t("resetPasswordSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.password ? "true" : "false"}
|
||||
aria-describedby={errors.password ? "password-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p id="password-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
||||
>
|
||||
{t("confirmPassword")}
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
aria-invalid={errors.confirmPassword ? "true" : "false"}
|
||||
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
|
||||
className={inputClassName}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
{errors.confirmPassword ? (
|
||||
<p id="confirm-password-error" className="text-sm text-red-600" role="alert">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isSubmitting}>
|
||||
{t("resetPasswordSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
171
apps/web/src/components/auth/security-content.tsx
Normal file
171
apps/web/src/components/auth/security-content.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { ApiError, confirmTwoFactor, setupTwoFactor, type TwoFactorSetupResponse } from "@/lib/api/auth";
|
||||
import { createTwoFactorSchema, type TwoFactorFormValues } from "@/lib/schemas/auth";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function SecurityContent() {
|
||||
const t = useTranslations("auth");
|
||||
const tv = useTranslations("validation");
|
||||
const { user, refresh } = useAuth();
|
||||
const [setup, setSetup] = useState<TwoFactorSetupResponse | null>(null);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const schema = createTwoFactorSchema({
|
||||
codeRequired: tv("codeRequired"),
|
||||
codeInvalid: tv("codeInvalid"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TwoFactorFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { code: "" },
|
||||
});
|
||||
|
||||
async function handleStartSetup() {
|
||||
setApiError(null);
|
||||
setIsStarting(true);
|
||||
|
||||
try {
|
||||
const response = await setupTwoFactor();
|
||||
setSetup(response);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(values: TwoFactorFormValues) {
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await confirmTwoFactor(values.code);
|
||||
setConfirmed(true);
|
||||
setSetup(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setApiError(error.detail ?? error.title);
|
||||
} else {
|
||||
setApiError(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{t("securityTitle")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("securitySubtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("twoFactorSetupTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{user?.twoFactorEnabled ? t("twoFactorEnabled") : t("twoFactorSetupDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{user?.twoFactorEnabled || confirmed ? (
|
||||
<p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100">
|
||||
{t("twoFactorEnabled")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!user?.twoFactorEnabled && !confirmed && !setup ? (
|
||||
<Button onClick={handleStartSetup} isLoading={isStarting}>
|
||||
{t("twoFactorSetupStart")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{setup ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("twoFactorScanQr")}</p>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={setup.qrCodeUrl}
|
||||
alt={t("twoFactorQrAlt")}
|
||||
className="h-48 w-48 rounded-md border border-zinc-200 dark:border-zinc-800"
|
||||
/>
|
||||
<p className="font-mono text-xs text-zinc-500">{setup.secret}</p>
|
||||
|
||||
{setup.recoveryCodes?.length ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
||||
<p className="mb-2 text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
{t("recoveryCodesTitle")}
|
||||
</p>
|
||||
<ul className="grid gap-1 font-mono text-xs text-amber-900 dark:text-amber-100 sm:grid-cols-2">
|
||||
{setup.recoveryCodes.map((code) => (
|
||||
<li key={code}>{code}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="code" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("twoFactorCode")}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
aria-invalid={errors.code ? "true" : "false"}
|
||||
className={inputClassName}
|
||||
{...register("code")}
|
||||
/>
|
||||
{errors.code ? (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{errors.code.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
{t("twoFactorConfirm")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{apiError && !setup ? (
|
||||
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/components/auth/verify-email-content.tsx
Normal file
129
apps/web/src/components/auth/verify-email-content.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ApiError, verifyEmail } from "@/lib/api/auth";
|
||||
|
||||
type VerifyState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function VerifyEmailContent() {
|
||||
const t = useTranslations("auth");
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
const sent = searchParams.get("sent");
|
||||
const [state, setState] = useState<VerifyState>(token ? "loading" : "idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function verify() {
|
||||
try {
|
||||
await verifyEmail(token!);
|
||||
if (!cancelled) {
|
||||
setState("success");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setState("error");
|
||||
if (error instanceof ApiError) {
|
||||
setErrorMessage(error.detail ?? error.title);
|
||||
} else {
|
||||
setErrorMessage(t("errors.generic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void verify();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, t]);
|
||||
|
||||
if (sent === "1") {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailSent")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailMissingToken")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "loading" || state === "idle") {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailLoading")}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "success") {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailSuccess")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
|
||||
<CardDescription>{t("verifyEmailError")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errorMessage ? (
|
||||
<p className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export function DashboardContent() {
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("welcome")}</p>
|
||||
<p className="mt-2 font-mono text-xs text-zinc-500">{t("protectedNote")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
48
apps/web/src/components/dashboard/dashboard-user-info.tsx
Normal file
48
apps/web/src/components/dashboard/dashboard-user-info.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { useAuth } from "@/components/providers/auth-provider";
|
||||
|
||||
export function DashboardUserInfo() {
|
||||
const t = useTranslations("common");
|
||||
const tAuth = useTranslations("auth");
|
||||
const { user, isLoading, logout } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="font-mono text-xs text-zinc-500">{t("loading")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{tAuth("sessionNone")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const displayLabel = user.displayName ?? user.username;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="text-right sm:text-left">
|
||||
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">{displayLabel}</p>
|
||||
<p className="font-mono text-xs text-zinc-500">{user.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/security"
|
||||
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{tAuth("securityTitle")}
|
||||
</Link>
|
||||
<Button variant="secondary" size="sm" onClick={() => void logout()}>
|
||||
{t("logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
apps/web/src/components/providers/auth-provider.tsx
Normal file
84
apps/web/src/components/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
getCurrentUser,
|
||||
submitLogout,
|
||||
type CurrentUser,
|
||||
} from "@/lib/api/auth";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: CurrentUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const currentUser = await getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await submitLogout();
|
||||
} finally {
|
||||
setUser(null);
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: user !== null,
|
||||
refresh,
|
||||
logout,
|
||||
}),
|
||||
[user, isLoading, refresh, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,45 +1,211 @@
|
||||
import { getApiUrl } from "../env";
|
||||
|
||||
export interface ProblemDetails {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
instance?: string;
|
||||
errors?: { field?: string; message: string }[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly title: string;
|
||||
readonly detail?: string;
|
||||
readonly errors?: { field?: string; message: string }[];
|
||||
|
||||
constructor(problem: ProblemDetails) {
|
||||
super(problem.detail ?? problem.title);
|
||||
this.name = "ApiError";
|
||||
this.status = problem.status;
|
||||
this.title = problem.title;
|
||||
this.detail = problem.detail;
|
||||
this.errors = problem.errors;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepared API client for Phase 1 authentication.
|
||||
* Currently validates structure only; no network request is performed.
|
||||
*/
|
||||
export async function submitLogin(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<AuthResponse> {
|
||||
const endpoint = `${getApiUrl()}/auth/login`;
|
||||
export interface LoginResponse {
|
||||
twoFactorRequired?: boolean;
|
||||
challengeId?: string;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
emailVerifiedAt?: string | null;
|
||||
roles?: string[];
|
||||
twoFactorEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorSetupResponse {
|
||||
secret: string;
|
||||
qrCodeUrl: string;
|
||||
recoveryCodes?: string[];
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
id: string;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
async function parseProblemResponse(response: Response): Promise<ApiError> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
if (contentType.includes("application/problem+json")) {
|
||||
const problem = (await response.json()) as ProblemDetails;
|
||||
return new ApiError({
|
||||
...problem,
|
||||
status: problem.status ?? response.status,
|
||||
title: problem.title ?? "Request failed",
|
||||
});
|
||||
}
|
||||
|
||||
return new ApiError({
|
||||
type: "about:blank",
|
||||
title: "Request failed",
|
||||
status: response.status,
|
||||
detail: response.statusText || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const endpoint = `${getApiUrl()}${path}`;
|
||||
|
||||
if (!endpoint.startsWith("http")) {
|
||||
throw new Error("Invalid API URL configuration");
|
||||
}
|
||||
|
||||
void credentials;
|
||||
void endpoint;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
return Promise.resolve({});
|
||||
if (options.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseProblemResponse(response);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export async function submitLogin(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<LoginResponse> {
|
||||
return apiFetch<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitRegister(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<AuthResponse> {
|
||||
const endpoint = `${getApiUrl()}/auth/register`;
|
||||
|
||||
if (!endpoint.startsWith("http")) {
|
||||
throw new Error("Invalid API URL configuration");
|
||||
}
|
||||
|
||||
void credentials;
|
||||
void endpoint;
|
||||
|
||||
return Promise.resolve({});
|
||||
payload: RegisterPayload,
|
||||
): Promise<void> {
|
||||
await apiFetch<void>("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitLogout(): Promise<void> {
|
||||
await apiFetch<void>("/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<CurrentUser> {
|
||||
return apiFetch<CurrentUser>("/me");
|
||||
}
|
||||
|
||||
export async function verifyEmail(token: string): Promise<void> {
|
||||
await apiFetch<void>("/auth/verify-email", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string): Promise<void> {
|
||||
await apiFetch<void>("/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetPassword(
|
||||
token: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
await apiFetch<void>("/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupTwoFactor(): Promise<TwoFactorSetupResponse> {
|
||||
return apiFetch<TwoFactorSetupResponse>("/auth/2fa/setup", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmTwoFactor(code: string): Promise<void> {
|
||||
await apiFetch<void>("/auth/2fa/confirm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyTwoFactorLogin(
|
||||
challengeId: string,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
await apiFetch<void>("/auth/2fa/verify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ challengeId, code }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSessions(): Promise<UserSession[]> {
|
||||
return apiFetch<UserSession[]>("/me/sessions");
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
await apiFetch<void>(`/me/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createRegisterSchema(messages: {
|
||||
passwordMin: string;
|
||||
confirmPasswordRequired: string;
|
||||
passwordMismatch: string;
|
||||
usernameInvalid?: string;
|
||||
}) {
|
||||
return z
|
||||
.object({
|
||||
@@ -32,6 +33,13 @@ export function createRegisterSchema(messages: {
|
||||
.string()
|
||||
.min(1, messages.emailRequired)
|
||||
.email(messages.emailInvalid),
|
||||
username: z
|
||||
.string()
|
||||
.refine((val) => val === "" || /^[a-zA-Z0-9_-]{3,32}$/.test(val), {
|
||||
message: messages.usernameInvalid ?? "Invalid username",
|
||||
})
|
||||
.default(""),
|
||||
displayName: z.string().max(64).default(""),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, messages.passwordRequired)
|
||||
@@ -44,5 +52,56 @@ export function createRegisterSchema(messages: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createForgotPasswordSchema(messages: {
|
||||
emailRequired: string;
|
||||
emailInvalid: string;
|
||||
}) {
|
||||
return z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, messages.emailRequired)
|
||||
.email(messages.emailInvalid),
|
||||
});
|
||||
}
|
||||
|
||||
export function createResetPasswordSchema(messages: {
|
||||
passwordRequired: string;
|
||||
passwordMin: string;
|
||||
confirmPasswordRequired: string;
|
||||
passwordMismatch: string;
|
||||
}) {
|
||||
return z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, messages.passwordRequired)
|
||||
.min(8, messages.passwordMin),
|
||||
confirmPassword: z.string().min(1, messages.confirmPasswordRequired),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: messages.passwordMismatch,
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
|
||||
export function createTwoFactorSchema(messages: {
|
||||
codeRequired: string;
|
||||
codeInvalid: string;
|
||||
}) {
|
||||
return z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(1, messages.codeRequired)
|
||||
.regex(/^\d{6}$/, messages.codeInvalid),
|
||||
});
|
||||
}
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;
|
||||
export type ForgotPasswordFormValues = z.infer<
|
||||
ReturnType<typeof createForgotPasswordSchema>
|
||||
>;
|
||||
export type ResetPasswordFormValues = z.infer<
|
||||
ReturnType<typeof createResetPasswordSchema>
|
||||
>;
|
||||
export type TwoFactorFormValues = z.infer<ReturnType<typeof createTwoFactorSchema>>;
|
||||
|
||||
@@ -1,7 +1,50 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { routing } from "./i18n/routing";
|
||||
|
||||
export default createMiddleware(routing);
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
const protectedPaths = ["/dashboard", "/security"];
|
||||
|
||||
function getPathWithoutLocale(pathname: string): string {
|
||||
if (pathname === "/en" || pathname.startsWith("/en/")) {
|
||||
return pathname.slice(3) || "/";
|
||||
}
|
||||
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function buildLocalizedPath(path: string, pathname: string): string {
|
||||
if (pathname === "/en" || pathname.startsWith("/en/")) {
|
||||
return `/en${path}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export default function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const pathWithoutLocale = getPathWithoutLocale(pathname);
|
||||
|
||||
const isProtected = protectedPaths.some(
|
||||
(protectedPath) =>
|
||||
pathWithoutLocale === protectedPath ||
|
||||
pathWithoutLocale.startsWith(`${protectedPath}/`),
|
||||
);
|
||||
|
||||
if (isProtected) {
|
||||
const session = request.cookies.get("hgc_session");
|
||||
|
||||
if (!session?.value) {
|
||||
const loginUrl = request.nextUrl.clone();
|
||||
loginUrl.pathname = buildLocalizedPath("/login", pathname);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return intlMiddleware(request);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,11 +15,14 @@
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"bullmq": "^5.34.10",
|
||||
"pino": "^9.6.0"
|
||||
"nodemailer": "^6.10.0",
|
||||
"pino": "^9.6.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
|
||||
38
apps/worker/src/config.ts
Normal file
38
apps/worker/src/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const notificationEmailJobSchema = z.object({
|
||||
type: z.enum(['email_verification', 'password_reset']),
|
||||
to: z.string().email(),
|
||||
subject: z.string(),
|
||||
template: z.string(),
|
||||
data: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type NotificationEmailJob = z.infer<typeof notificationEmailJobSchema>;
|
||||
|
||||
const smtpConfigSchema = z.object({
|
||||
SMTP_HOST: z.string().min(1).default('localhost'),
|
||||
SMTP_PORT: z.coerce.number().int().default(1025),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('noreply@localhost'),
|
||||
SMTP_TLS: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === 'true' || v === '1'),
|
||||
APP_NAME: z.string().default('HexaHost GameCloud'),
|
||||
});
|
||||
|
||||
export type SmtpConfig = z.infer<typeof smtpConfigSchema>;
|
||||
|
||||
export function getSmtpConfig(): SmtpConfig {
|
||||
return smtpConfigSchema.parse({
|
||||
SMTP_HOST: process.env['SMTP_HOST'],
|
||||
SMTP_PORT: process.env['SMTP_PORT'],
|
||||
SMTP_USER: process.env['SMTP_USER'],
|
||||
SMTP_PASSWORD: process.env['SMTP_PASSWORD'],
|
||||
SMTP_FROM: process.env['SMTP_FROM'],
|
||||
SMTP_TLS: process.env['SMTP_TLS'],
|
||||
APP_NAME: process.env['APP_NAME'],
|
||||
});
|
||||
}
|
||||
45
apps/worker/src/email.ts
Normal file
45
apps/worker/src/email.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
import { getSmtpConfig, type SmtpConfig } from './config';
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
function getTransporter(config: SmtpConfig): Transporter {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.SMTP_HOST,
|
||||
port: config.SMTP_PORT,
|
||||
secure: config.SMTP_TLS,
|
||||
auth:
|
||||
config.SMTP_USER && config.SMTP_PASSWORD
|
||||
? { user: config.SMTP_USER, pass: config.SMTP_PASSWORD }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
export interface SendEmailPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(payload: SendEmailPayload): Promise<void> {
|
||||
const config = getSmtpConfig();
|
||||
const transport = getTransporter(config);
|
||||
|
||||
await transport.sendMail({
|
||||
from: config.SMTP_FROM,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
html: payload.html,
|
||||
text: payload.text ?? stripHtml(payload.html),
|
||||
});
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
33
apps/worker/src/handlers/notifications.ts
Normal file
33
apps/worker/src/handlers/notifications.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Job } from 'bullmq';
|
||||
|
||||
import { getSmtpConfig, notificationEmailJobSchema } from '../config';
|
||||
import { sendEmail } from '../email';
|
||||
import { logger } from '../logger';
|
||||
|
||||
export async function processNotificationJob(job: Job): Promise<{ sent: boolean }> {
|
||||
if (job.name !== 'send-email') {
|
||||
logger.warn({ jobName: job.name }, 'Unknown notification job');
|
||||
return { sent: false };
|
||||
}
|
||||
|
||||
const payload = notificationEmailJobSchema.parse(job.data);
|
||||
const config = getSmtpConfig();
|
||||
|
||||
let html: string;
|
||||
if (payload.type === 'email_verification') {
|
||||
const verifyUrl = String(payload.data['verifyUrl'] ?? '');
|
||||
html = `<p>Welcome to ${config.APP_NAME}!</p><p><a href="${verifyUrl}">Verify your email address</a></p><p>This link expires in 24 hours.</p>`;
|
||||
} else {
|
||||
const resetUrl = String(payload.data['resetUrl'] ?? '');
|
||||
html = `<p>You requested a password reset for ${config.APP_NAME}.</p><p><a href="${resetUrl}">Set a new password</a></p><p>If you did not request this, ignore this email.</p>`;
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
html,
|
||||
});
|
||||
|
||||
logger.info({ to: payload.to, type: payload.type, jobId: job.id }, 'Email sent');
|
||||
return { sent: true };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Job, Worker, type ConnectionOptions } from 'bullmq';
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { processNotificationJob } from './handlers/notifications';
|
||||
import { startHealthServer } from './health';
|
||||
import { logger } from './logger';
|
||||
import { WORKER_QUEUES, type WorkerQueueName } from './queues';
|
||||
@@ -19,6 +20,10 @@ function createQueueWorker(
|
||||
'Processing job',
|
||||
);
|
||||
|
||||
if (queueName === 'notifications') {
|
||||
return processNotificationJob(job);
|
||||
}
|
||||
|
||||
return { acknowledged: true, queue: queueName };
|
||||
},
|
||||
{ connection },
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user