diff --git a/apps/api/.env.example b/apps/api/.env.example index 06140fb..d4af5f4 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -48,6 +48,10 @@ BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:*,http://*.localhost:*,https://bina GOOGLE_CLIENT_ID=your_client_id_here GOOGLE_CLIENT_SECRET=your_client_secret_here +# Field-level encryption for OAuth tokens at rest (AES-256-GCM). +# Generate with: openssl rand -hex 32 +FIELD_ENCRYPTION_KEY=your_64_char_hex_key_here + # Email (Resend) RESEND_API_KEY=re_your_api_key_here RESEND_FROM_EMAIL=onboarding@resend.dev diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cae1c32..a298ce9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -21,6 +21,7 @@ import { parseNodeEnvironment } from './lib/types/environment.type'; import { TenantModule } from './modules/tenant/tenant.module'; import { UserProfileModule } from './modules/user-profile/user-profile.module'; import { GlobalCrudModule } from './modules/global-crud/global-crud.module'; +import { GdprModule } from './modules/gdpr/gdpr.module'; @Module({ imports: [ @@ -70,6 +71,7 @@ import { GlobalCrudModule } from './modules/global-crud/global-crud.module'; CrudModule, GlobalCrudModule, EmailModule, + GdprModule, ], controllers: [], providers: [AppContext], diff --git a/apps/api/src/lib/field-encryption.ts b/apps/api/src/lib/field-encryption.ts new file mode 100644 index 0000000..8699df9 --- /dev/null +++ b/apps/api/src/lib/field-encryption.ts @@ -0,0 +1,81 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; +import { Logger } from '@repo/utils-core'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const PREFIX = 'enc:'; + +let cachedKey: Buffer | null = null; + +function getKey(): Buffer | null { + if (cachedKey) return cachedKey; + + const hex = process.env.FIELD_ENCRYPTION_KEY; + if (!hex) return null; + + if (hex.length !== 64) { + Logger.instance + .withContext('FieldEncryption') + .critical( + 'FIELD_ENCRYPTION_KEY must be a 64-char hex string (32 bytes). Encryption disabled.', + ); + return null; + } + + cachedKey = Buffer.from(hex, 'hex'); + return cachedKey; +} + +export function encryptField(plaintext: string): string { + const key = getKey(); + if (!key) return plaintext; + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return `${PREFIX}${iv.toString('base64')}.${authTag.toString('base64')}.${encrypted.toString('base64')}`; +} + +/** + * Decrypts a field value. If the value wasn't encrypted (no prefix), + * returns it as-is — this provides a smooth migration path for + * pre-existing plaintext data. + */ +export function decryptField(ciphertext: string): string { + if (!ciphertext.startsWith(PREFIX)) return ciphertext; + + const key = getKey(); + if (!key) return ciphertext; + + try { + const payload = ciphertext.slice(PREFIX.length); + const [ivB64, tagB64, dataB64] = payload.split('.'); + const iv = Buffer.from(ivB64, 'base64'); + const authTag = Buffer.from(tagB64, 'base64'); + const encrypted = Buffer.from(dataB64, 'base64'); + + const decipher = createDecipheriv(ALGORITHM, key, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + return decrypted.toString('utf8'); + } catch { + Logger.instance + .withContext('FieldEncryption') + .critical('Failed to decrypt field — returning raw value'); + return ciphertext; + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index af98602..7eb4627 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -6,6 +6,9 @@ import { BetterAuthLogger } from '../logger/logger-better-auth'; import { AuthService } from './auth.service'; import { AuthMiddleware } from './auth.middleware'; import { createBetterAuth } from './auth'; +import { UserPrismaRepository } from './repositories/prisma/user.prisma-repository'; +import { AccountPrismaRepository } from './repositories/prisma/account.prisma-repository'; +import { SessionPrismaRepository } from './repositories/prisma/session.prisma-repository'; @Module({ imports: [ @@ -29,7 +32,19 @@ import { createBetterAuth } from './auth'; }), EmailModule, ], - providers: [AuthService, AuthMiddleware], - exports: [AuthService, AuthMiddleware], + providers: [ + AuthService, + AuthMiddleware, + UserPrismaRepository, + AccountPrismaRepository, + SessionPrismaRepository, + ], + exports: [ + AuthService, + AuthMiddleware, + UserPrismaRepository, + AccountPrismaRepository, + SessionPrismaRepository, + ], }) export class AuthModule {} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 6835f54..381fcb9 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -53,6 +53,11 @@ export class AuthService { return session?.session ?? null; } + async deleteUserForContext(ctx: AppContextType): Promise { + const headers = fromNodeHeaders(ctx.req.headers); + await this.auth.api.deleteUser({ headers, body: {} }); + } + async getAccessTokenForContext( provider: 'google', ctx: AppContextType, diff --git a/apps/api/src/modules/auth/auth.ts b/apps/api/src/modules/auth/auth.ts index f2fc0d2..ba3ee50 100644 --- a/apps/api/src/modules/auth/auth.ts +++ b/apps/api/src/modules/auth/auth.ts @@ -10,9 +10,107 @@ import { NodeEnvironment, parseNodeEnvironment, } from '../../lib/types/environment.type'; +import { Logger } from '@repo/utils-core'; +import { encryptField, decryptField } from '../../lib/field-encryption'; + +const TOKEN_FIELDS = ['accessToken', 'refreshToken', 'idToken'] as const; + +function encryptTokenFields(data: Record): void { + for (const field of TOKEN_FIELDS) { + if (typeof data[field] === 'string') { + data[field] = encryptField(data[field]); + } + } +} + +function decryptTokenFields(record: Record): void { + for (const field of TOKEN_FIELDS) { + if (typeof record[field] === 'string') { + record[field] = decryptField(record[field]); + } + } +} + +function decryptResult(result: unknown): void { + if (Array.isArray(result)) { + for (const r of result) decryptTokenFields(r as Record); + } else if (result && typeof result === 'object') { + decryptTokenFields(result as Record); + } +} + +const basePrisma = new PrismaClient(); + +/** + * Wraps a Prisma model delegate so that token fields on the Account + * model are encrypted before writes and decrypted after reads. + * Uses a JS Proxy so it works regardless of how the caller accesses + * methods (typed or dynamic string indexing). + */ +function wrapAccountDelegate(delegate: Record): unknown { + const WRITE_METHODS = new Set(['create', 'update', 'upsert']); + const READ_METHODS = new Set([ + 'findUnique', + 'findFirst', + 'findMany', + 'create', + 'update', + 'upsert', + ]); + + return new Proxy(delegate, { + get(target, prop) { + const original = target[prop as string]; + if (typeof original !== 'function') return original; + + const method = String(prop); + + if (WRITE_METHODS.has(method) || READ_METHODS.has(method)) { + return async (...args: unknown[]) => { + const params = args[0] as Record | undefined; + + if (WRITE_METHODS.has(method) && params) { + if (method === 'upsert') { + if (params.create) + encryptTokenFields(params.create as Record); + if (params.update) + encryptTokenFields(params.update as Record); + } else if (params.data) { + encryptTokenFields(params.data as Record); + } + } + + const result: unknown = await original.apply(target, args); + + if (READ_METHODS.has(method)) { + decryptResult(result); + } + + return result; + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return original.bind(target); + }, + }); +} + +const prisma = new Proxy(basePrisma, { + get(target, prop) { + const value = (target as unknown as Record)[prop]; + if (prop === 'account') { + return wrapAccountDelegate(value as Record); + } + if (typeof value === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value.bind(target); + } + return value; + }, +}) as unknown as PrismaClient; const createDatabaseAdapter = () => { - const prisma = new PrismaClient(); return prismaAdapter(prisma, { provider: 'postgresql', }); @@ -30,6 +128,77 @@ export const createBetterAuth = ( trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') || [], basePath: '/api/auth', database: createDatabaseAdapter(), + databaseHooks: { + user: { + create: { + after: async (user, ctx) => { + const ipAddress = + ctx?.headers?.get?.('x-forwarded-for')?.split(',')[0]?.trim() ?? + ctx?.headers?.get?.('x-real-ip') ?? + null; + + try { + await basePrisma.user.update({ + where: { id: user.id }, + data: { + consentGiven: true, + consentAt: new Date(), + consentIp: ipAddress, + }, + }); + await basePrisma.gdprAuditLog.create({ + data: { + userId: user.id, + action: 'CONSENT_GIVEN', + details: 'Consent recorded at account creation', + ipAddress, + }, + }); + } catch (err) { + Logger.instance + .withContext('Auth') + .critical('Failed to record GDPR consent', err); + } + }, + }, + }, + }, + user: { + deleteUser: { + enabled: true, + beforeDelete: async (user, request) => { + const email = user.email?.trim().toLowerCase(); + const ipAddress = + request?.headers?.get?.('x-forwarded-for')?.split(',')[0]?.trim() ?? + request?.headers?.get?.('x-real-ip') ?? + null; + + try { + await basePrisma.gdprAuditLog.create({ + data: { + userId: user.id, + action: 'DATA_DELETION', + details: 'Account deletion requested and executed', + ipAddress, + }, + }); + + if (email) { + await basePrisma.tenantMembership.deleteMany({ + where: { email }, + }); + await basePrisma.verification.deleteMany({ + where: { identifier: email }, + }); + } + } catch (err) { + Logger.instance + .withContext('Auth') + .critical('Failed to execute pre-deletion cleanup', err); + } + }, + }, + }, emailAndPassword: { enabled: true, requireEmailVerification: !isDevelopment, diff --git a/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.interface.ts b/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.interface.ts new file mode 100644 index 0000000..2adaa3d --- /dev/null +++ b/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.interface.ts @@ -0,0 +1,7 @@ +import { Prisma } from '@repo/prisma-db'; + +export interface IAccountPrismaRepository { + findMany( + args?: Prisma.AccountFindManyArgs, + ): Promise[]>; +} diff --git a/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.ts b/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.ts new file mode 100644 index 0000000..0210ebe --- /dev/null +++ b/apps/api/src/modules/auth/repositories/prisma/account.prisma-repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { + TransactionHost, + InjectTransactionHost, +} from '@nestjs-cls/transactional'; +import { Prisma } from '@repo/prisma-db'; +import { IAccountPrismaRepository } from './account.prisma-repository.interface'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; +import { ServerConstants } from '../../../../constants/server.constants'; + +@Injectable() +export class AccountPrismaRepository implements IAccountPrismaRepository { + constructor( + @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma) + protected readonly prismaTxHost: TransactionHost, + ) {} + + protected get delegate(): Prisma.AccountDelegate { + return this.prismaTxHost.tx.account; + } + + findMany( + args?: Prisma.AccountFindManyArgs, + ): Promise[]> { + return this.delegate.findMany(args); + } +} diff --git a/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.interface.ts b/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.interface.ts new file mode 100644 index 0000000..edd57c6 --- /dev/null +++ b/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.interface.ts @@ -0,0 +1,7 @@ +import { Prisma } from '@repo/prisma-db'; + +export interface ISessionPrismaRepository { + findMany( + args?: Prisma.SessionFindManyArgs, + ): Promise[]>; +} diff --git a/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.ts b/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.ts new file mode 100644 index 0000000..2d458d8 --- /dev/null +++ b/apps/api/src/modules/auth/repositories/prisma/session.prisma-repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { + TransactionHost, + InjectTransactionHost, +} from '@nestjs-cls/transactional'; +import { Prisma } from '@repo/prisma-db'; +import { ISessionPrismaRepository } from './session.prisma-repository.interface'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; +import { ServerConstants } from '../../../../constants/server.constants'; + +@Injectable() +export class SessionPrismaRepository implements ISessionPrismaRepository { + constructor( + @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma) + protected readonly prismaTxHost: TransactionHost, + ) {} + + protected get delegate(): Prisma.SessionDelegate { + return this.prismaTxHost.tx.session; + } + + findMany( + args?: Prisma.SessionFindManyArgs, + ): Promise[]> { + return this.delegate.findMany(args); + } +} diff --git a/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.interface.ts b/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.interface.ts new file mode 100644 index 0000000..f6db513 --- /dev/null +++ b/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.interface.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@repo/prisma-db'; + +export interface IUserPrismaRepository { + findUniqueOrThrow( + args: Prisma.UserFindUniqueOrThrowArgs, + ): Promise>; + + update( + args: Prisma.UserUpdateArgs, + ): Promise>; +} diff --git a/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.ts b/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.ts new file mode 100644 index 0000000..9154e8e --- /dev/null +++ b/apps/api/src/modules/auth/repositories/prisma/user.prisma-repository.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { + TransactionHost, + InjectTransactionHost, +} from '@nestjs-cls/transactional'; +import { Prisma } from '@repo/prisma-db'; +import { IUserPrismaRepository } from './user.prisma-repository.interface'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; +import { ServerConstants } from '../../../../constants/server.constants'; + +@Injectable() +export class UserPrismaRepository implements IUserPrismaRepository { + constructor( + @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma) + protected readonly prismaTxHost: TransactionHost, + ) {} + + protected get delegate(): Prisma.UserDelegate { + return this.prismaTxHost.tx.user; + } + + findUniqueOrThrow( + args: Prisma.UserFindUniqueOrThrowArgs, + ): Promise> { + return this.delegate.findUniqueOrThrow(args); + } + + update( + args: Prisma.UserUpdateArgs, + ): Promise> { + return this.delegate.update(args); + } +} diff --git a/apps/api/src/modules/email/email.service.ts b/apps/api/src/modules/email/email.service.ts index daa7644..3cf0988 100644 --- a/apps/api/src/modules/email/email.service.ts +++ b/apps/api/src/modules/email/email.service.ts @@ -97,37 +97,27 @@ export class EmailService { ); try { - let messageId: string; + const payload = { + to, + subject: renderedEmail.subject, + html: renderedEmail.html, + text: renderedEmail.text, + }; switch (provider) { case EmailProvider.RESEND: - messageId = await this.resendProvider.send({ - to, - subject: renderedEmail.subject, - html: renderedEmail.html, - text: renderedEmail.text, - }); + await this.resendProvider.send(payload); break; case EmailProvider.AWS_SES: - messageId = await this.awsSesProvider.send({ - to, - subject: renderedEmail.subject, - html: renderedEmail.html, - text: renderedEmail.text, - }); + await this.awsSesProvider.send(payload); break; default: throw new Error(`Unsupported email provider: ${String(provider)}`); } - Logger.instance.info( - `Email (${templateName}) sent successfully to ${to} via ${provider}. ID: ${messageId}`, - ); + Logger.instance.info(`Email (${templateName}) sent successfully`); } catch (error) { - Logger.instance.critical( - `Failed to send email (${templateName}) to ${to} via ${provider}:`, - error, - ); + Logger.instance.critical(`Failed to send email (${templateName})`, error); } } } diff --git a/apps/api/src/modules/gdpr/gdpr.module.ts b/apps/api/src/modules/gdpr/gdpr.module.ts new file mode 100644 index 0000000..1c22f20 --- /dev/null +++ b/apps/api/src/modules/gdpr/gdpr.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; +import { UserProfileModule } from '../user-profile/user-profile.module'; +import { GdprService } from './gdpr.service'; +import { GdprRouter } from './gdpr.router'; +import { GdprAuditLogPrismaRepository } from './repositories/prisma/gdpr-audit-log.prisma-repository'; + +@Module({ + imports: [PrismaModule, AuthModule, UserProfileModule], + providers: [GdprService, GdprRouter, GdprAuditLogPrismaRepository], + exports: [GdprService], +}) +export class GdprModule {} diff --git a/apps/api/src/modules/gdpr/gdpr.router.ts b/apps/api/src/modules/gdpr/gdpr.router.ts new file mode 100644 index 0000000..7d77ba8 --- /dev/null +++ b/apps/api/src/modules/gdpr/gdpr.router.ts @@ -0,0 +1,89 @@ +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from 'nestjs-trpc'; +import { TRPCError } from '@trpc/server'; +import { AuthMiddleware } from '../auth/auth.middleware'; +import { GdprService } from './gdpr.service'; +import { AppContextType } from '../../app.context'; +import { + ZGdprMyDataResponse, + ZGdprUpdateProfileRequest, + ZGdprUpdateProfileResponse, + ZGdprDeleteAccountRequest, + ZGdprDeleteAccountResponse, + TGdprMyDataResponse, + TGdprUpdateProfileRequest, + TGdprUpdateProfileResponse, + TGdprDeleteAccountRequest, + TGdprDeleteAccountResponse, +} from './gdpr.schema'; + +@Router({ alias: 'gdpr' }) +@UseMiddlewares(AuthMiddleware) +export class GdprRouter { + constructor(private readonly gdprService: GdprService) {} + + @Query({ output: ZGdprMyDataResponse }) + async myData(@Ctx() ctx: AppContextType): Promise { + const user = ctx.user!; + return this.gdprService.getMyData(user.id, this.extractIp(ctx)); + } + + @Query({ output: ZGdprMyDataResponse }) + async exportData(@Ctx() ctx: AppContextType): Promise { + const user = ctx.user!; + return this.gdprService.getMyData(user.id, this.extractIp(ctx)); + } + + @Mutation({ + input: ZGdprUpdateProfileRequest, + output: ZGdprUpdateProfileResponse, + }) + async updateProfile( + @Ctx() ctx: AppContextType, + @Input() req: TGdprUpdateProfileRequest, + ): Promise { + const user = ctx.user!; + return this.gdprService.updateProfile( + user.id, + { name: req.name, image: req.image }, + this.extractIp(ctx), + ); + } + + @Mutation({ + input: ZGdprDeleteAccountRequest, + output: ZGdprDeleteAccountResponse, + }) + async deleteAccount( + @Ctx() ctx: AppContextType, + @Input() req: TGdprDeleteAccountRequest, + ): Promise { + const user = ctx.user!; + + if (req.confirmation !== user.email) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'Confirmation must match your email address to delete your account', + }); + } + + await this.gdprService.deleteAccount(ctx); + + return { success: true }; + } + + private extractIp(ctx: AppContextType): string | null { + const headers = ctx.req.headers; + const forwarded = headers['x-forwarded-for']; + if (typeof forwarded === 'string') return forwarded.split(',')[0].trim(); + if (Array.isArray(forwarded)) return forwarded[0]; + return (headers['x-real-ip'] as string) ?? null; + } +} diff --git a/apps/api/src/modules/gdpr/gdpr.schema.ts b/apps/api/src/modules/gdpr/gdpr.schema.ts new file mode 100644 index 0000000..3c905fe --- /dev/null +++ b/apps/api/src/modules/gdpr/gdpr.schema.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { ZBaseRequest, ZBaseResponse } from '../../schemas/base.schema'; + +// ─── My Data (Right of Access / Portability) ──────────────────── + +export const ZGdprMyDataResponse = ZBaseResponse.extend({ + user: z.object({ + name: z.string(), + email: z.string(), + emailVerified: z.boolean(), + image: z.string().nullable(), + consentGiven: z.boolean(), + consentAt: z.date().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + }), + accounts: z.array( + z.object({ + providerId: z.string(), + scope: z.string().nullable(), + createdAt: z.date(), + }), + ), + sessions: z.array( + z.object({ + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), + createdAt: z.date(), + expiresAt: z.date(), + }), + ), + profile: z + .object({ + createdAt: z.date(), + }) + .nullable(), +}); + +// ─── Update Profile (Right of Rectification) ─────────────────── + +export const ZGdprUpdateProfileRequest = ZBaseRequest.extend({ + name: z + .string() + .min(2) + .max(100) + .regex( + /^[a-zA-Z\s'-]+$/, + 'Name can only contain letters, spaces, hyphens, and apostrophes', + ) + .optional(), + image: z.string().max(2048).nullable().optional(), +}); + +export const ZGdprUpdateProfileResponse = ZBaseResponse.extend({ + user: z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), + updatedAt: z.date(), + }), +}); + +// ─── Delete Account (Right to Erasure) ────────────────────────── + +export const ZGdprDeleteAccountRequest = ZBaseRequest.extend({ + confirmation: z.string(), +}); + +export const ZGdprDeleteAccountResponse = ZBaseResponse; + +// ─── Types ────────────────────────────────────────────────────── + +export type TGdprMyDataResponse = z.infer; +export type TGdprUpdateProfileRequest = z.infer< + typeof ZGdprUpdateProfileRequest +>; +export type TGdprUpdateProfileResponse = z.infer< + typeof ZGdprUpdateProfileResponse +>; +export type TGdprDeleteAccountRequest = z.infer< + typeof ZGdprDeleteAccountRequest +>; +export type TGdprDeleteAccountResponse = z.infer< + typeof ZGdprDeleteAccountResponse +>; diff --git a/apps/api/src/modules/gdpr/gdpr.service.ts b/apps/api/src/modules/gdpr/gdpr.service.ts new file mode 100644 index 0000000..057e34d --- /dev/null +++ b/apps/api/src/modules/gdpr/gdpr.service.ts @@ -0,0 +1,139 @@ +import { Injectable } from '@nestjs/common'; +import { Propagation } from '@nestjs-cls/transactional'; +import { AutoTransaction } from '../../decorators/class/auto-transaction.decorator'; +import { ServerConstants } from '../../constants/server.constants'; +import { Logger } from '@repo/utils-core'; +import { AuthService } from '../auth/auth.service'; +import { UserPrismaRepository } from '../auth/repositories/prisma/user.prisma-repository'; +import { AccountPrismaRepository } from '../auth/repositories/prisma/account.prisma-repository'; +import { SessionPrismaRepository } from '../auth/repositories/prisma/session.prisma-repository'; +import { UserProfilePrismaRepository } from '../user-profile/repositories/prisma/user-profile.prisma-repository'; +import { GdprAuditLogPrismaRepository } from './repositories/prisma/gdpr-audit-log.prisma-repository'; +import { AppContextType } from '../../app.context'; +import type { + TGdprMyDataResponse, + TGdprUpdateProfileResponse, +} from './gdpr.schema'; + +@Injectable() +@AutoTransaction( + ServerConstants.TransactionConnectionNames.Prisma, + Propagation.Required, +) +export class GdprService { + constructor( + private readonly authService: AuthService, + private readonly userRepository: UserPrismaRepository, + private readonly accountRepository: AccountPrismaRepository, + private readonly sessionRepository: SessionPrismaRepository, + private readonly userProfileRepository: UserProfilePrismaRepository, + private readonly auditLogRepository: GdprAuditLogPrismaRepository, + ) {} + + private get logger() { + return Logger.instance.withContext(GdprService.name); + } + + async getMyData( + userId: string, + ipAddress: string | null, + ): Promise { + const user = await this.userRepository.findUniqueOrThrow({ + where: { id: userId }, + select: { + name: true, + email: true, + emailVerified: true, + image: true, + consentGiven: true, + consentAt: true, + createdAt: true, + updatedAt: true, + }, + }); + + const accounts = await this.accountRepository.findMany({ + where: { userId }, + select: { + providerId: true, + scope: true, + createdAt: true, + }, + }); + + const sessions = await this.sessionRepository.findMany({ + where: { userId }, + select: { + ipAddress: true, + userAgent: true, + createdAt: true, + expiresAt: true, + }, + }); + + const profile = await this.userProfileRepository.findUnique({ + where: { userId }, + select: { + createdAt: true, + }, + }); + + await this.auditLogRepository.create({ + data: { + userId, + action: 'DATA_ACCESS', + details: 'User requested personal data export', + ipAddress, + }, + }); + + return { + success: true, + user, + accounts, + sessions, + profile, + }; + } + + async updateProfile( + userId: string, + data: { name?: string; image?: string | null }, + ipAddress: string | null, + ): Promise { + const updateData: Record = {}; + if (data.name !== undefined) updateData.name = data.name.trim(); + if (data.image !== undefined) updateData.image = data.image; + + const user = await this.userRepository.update({ + where: { id: userId }, + data: updateData, + select: { + id: true, + name: true, + email: true, + image: true, + updatedAt: true, + }, + }); + + const changedFields = Object.keys(updateData).join(', '); + await this.auditLogRepository.create({ + data: { + userId, + action: 'DATA_RECTIFICATION', + details: `Profile fields updated: ${changedFields}`, + ipAddress, + }, + }); + + this.logger.info(`Profile updated for user ${userId}`); + + return { success: true, user }; + } + + async deleteAccount(ctx: AppContextType): Promise { + await this.authService.deleteUserForContext(ctx); + this.logger.info(`Account deleted for user ${ctx.user!.id}`); + } +} diff --git a/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.interface.ts b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.interface.ts new file mode 100644 index 0000000..6d43948 --- /dev/null +++ b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.interface.ts @@ -0,0 +1,7 @@ +import { Prisma } from '@repo/prisma-db'; + +export interface IGdprAuditLogPrismaRepository { + create( + args: Prisma.GdprAuditLogCreateArgs, + ): Promise>; +} diff --git a/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.ts b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.ts new file mode 100644 index 0000000..1a1ca6b --- /dev/null +++ b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { + TransactionHost, + InjectTransactionHost, +} from '@nestjs-cls/transactional'; +import { Prisma } from '@repo/prisma-db'; +import { IGdprAuditLogPrismaRepository } from './gdpr-audit-log.prisma-repository.interface'; +import { PrismaTransactionAdapter } from '../../../prisma/prisma.module'; +import { ServerConstants } from '../../../../constants/server.constants'; + +@Injectable() +export class GdprAuditLogPrismaRepository implements IGdprAuditLogPrismaRepository { + constructor( + @InjectTransactionHost(ServerConstants.TransactionConnectionNames.Prisma) + protected readonly prismaTxHost: TransactionHost, + ) {} + + protected get delegate(): Prisma.GdprAuditLogDelegate { + return this.prismaTxHost.tx.gdprAuditLog; + } + + create( + args: Prisma.GdprAuditLogCreateArgs, + ): Promise> { + return this.delegate.create(args); + } +} diff --git a/apps/api/src/trpc/trpc-error-formatter.ts b/apps/api/src/trpc/trpc-error-formatter.ts index eef2564..2626181 100644 --- a/apps/api/src/trpc/trpc-error-formatter.ts +++ b/apps/api/src/trpc/trpc-error-formatter.ts @@ -217,15 +217,10 @@ function getFormattedTRPCError(opts: TRPCErrorOptions): FormattedError { export function trpcErrorFormatter(opts: TRPCErrorOptions): TRPCErrorShapeType { const formattedError: FormattedError = getFormattedTRPCError(opts); - const { input } = opts; const logArguments = { ...formattedError, }; - if (input && process.env.NODE_ENV === 'development') { - Object.assign(logArguments, { input: input }); - } - Logger.instance .withContext('TRPC Route') .critical(formattedError.errorShape.message, logArguments); diff --git a/apps/web/app/auth-demo/AuthDemoClient.tsx b/apps/web/app/auth-demo/AuthDemoClient.tsx index 7eb2106..d699452 100644 --- a/apps/web/app/auth-demo/AuthDemoClient.tsx +++ b/apps/web/app/auth-demo/AuthDemoClient.tsx @@ -265,7 +265,7 @@ export default function AuthDemoClient() {

-

User ID

+

{LL.Auth.userId()}

{session.user.id}

@@ -309,6 +309,19 @@ export default function AuthDemoClient() { {authStep === "choose" && ( <> +

+ {LL.Auth.signInConsentPrefix()}{" "} + + {LL.Settings.privacyAndDataPolicy()} + {" "} + {LL.Auth.signInConsentSuffix()} +

+ +
+ + ); +} diff --git a/apps/web/app/components/layout/app-sidebar.tsx b/apps/web/app/components/layout/app-sidebar.tsx index 21e6a34..2633a33 100644 --- a/apps/web/app/components/layout/app-sidebar.tsx +++ b/apps/web/app/components/layout/app-sidebar.tsx @@ -19,16 +19,19 @@ import { Menu, X, } from "lucide-react"; -import { navSections, type NavItem } from "./sidebar-nav-items"; +import { getNavSections, type NavItem } from "./sidebar-nav-items"; import { useAuthClient } from "../../lib/auth/auth-client"; +import { useI18n } from "../../hooks/useI18n"; import { Avatar } from "../ui/avatar"; const STORAGE_KEY = "sidebar-collapsed"; export function AppSidebar() { + const { LL } = useI18n(); const pathname = usePathname(); const searchParams = useSearchParams(); const authClient = useAuthClient(); + const navSections = getNavSections(LL); const sessionResult = authClient?.useSession?.(); const session = sessionResult?.data; const user = session?.user; @@ -100,7 +103,7 @@ export function AppSidebar() { > {!collapsed && ( - Navigation + {LL.Sidebar.navigation()} )} ))} {/* Description */}

- {TAB_DESCRIPTIONS[activeTab]} + {tabDescriptions[activeTab]}

{/* Panels */} @@ -160,6 +156,7 @@ interface CrudRecord { } function CrudLiveGrid() { + const { LL } = useI18n(); const query = trpc.crud.findAllPrisma.useQuery( {}, { refetchOnWindowFocus: false }, @@ -169,16 +166,20 @@ function CrudLiveGrid() { (query.data as { cruds?: CrudRecord[] } | undefined)?.cruds ?? []; const columns: ColumnDef[] = [ - { accessorKey: "id", header: "ID", cell: ReadOnlyCell }, - { accessorKey: "content", header: "Content", cell: ReadOnlyCell }, + { accessorKey: "id", header: LL.Grids.colId(), cell: ReadOnlyCell }, + { + accessorKey: "content", + header: LL.Grids.colContent(), + cell: ReadOnlyCell, + }, { accessorKey: "createdAt", - header: "Created At", + header: LL.Grids.colCreatedAt(), cell: DateCell, }, { accessorKey: "updatedAt", - header: "Updated At", + header: LL.Grids.colUpdatedAt(), cell: DateCell, }, ]; @@ -187,7 +188,7 @@ function CrudLiveGrid() { return (
- Loading Crud records… + {LL.Grids.loadingCrudRecords()}
); } @@ -196,7 +197,7 @@ function CrudLiveGrid() { return (
- Failed to load: {query.error.message} + {LL.Grids.failedToLoad({ message: query.error.message })}
); } @@ -206,10 +207,10 @@ function CrudLiveGrid() {
- Live — Prisma + {LL.Grids.livePrisma()} - {data.length} record{data.length !== 1 && "s"} + {LL.Grids.recordCount({ count: data.length })}
[] = [ - { accessorKey: "id", header: "ID", cell: ReadOnlyCell }, - { accessorKey: "content", header: "Content", cell: ReadOnlyCell }, + { accessorKey: "id", header: LL.Grids.colId(), cell: ReadOnlyCell }, + { + accessorKey: "content", + header: LL.Grids.colContent(), + cell: ReadOnlyCell, + }, { accessorKey: "createdAt", - header: "Created At", + header: LL.Grids.colCreatedAt(), cell: DateCell, }, { accessorKey: "updatedAt", - header: "Updated At", + header: LL.Grids.colUpdatedAt(), cell: DateCell, }, ]; @@ -267,7 +273,7 @@ function GlobalCrudLiveGrid() { return (
- Loading Global Crud records… + {LL.Grids.loadingGlobalCrudRecords()}
); } @@ -276,7 +282,7 @@ function GlobalCrudLiveGrid() { return (
- Failed to load: {query.error.message} + {LL.Grids.failedToLoad({ message: query.error.message })}
); } @@ -286,10 +292,10 @@ function GlobalCrudLiveGrid() {
- Live — Prisma + {LL.Grids.livePrisma()} - {data.length} record{data.length !== 1 && "s"} + {LL.Grids.recordCount({ count: data.length })}
(() => [...initialPeople]); const [modified, setModified] = useState(false); @@ -358,18 +365,26 @@ function CrudGrid() { }, []); const columns: ColumnDef[] = [ - { accessorKey: "firstName", header: "First Name", cell: EditableCell }, - { accessorKey: "lastName", header: "Last Name", cell: EditableCell }, - { accessorKey: "email", header: "Email", cell: EditableCell }, + { + accessorKey: "firstName", + header: LL.Grids.colFirstName(), + cell: EditableCell, + }, + { + accessorKey: "lastName", + header: LL.Grids.colLastName(), + cell: EditableCell, + }, + { accessorKey: "email", header: LL.Grids.colEmail(), cell: EditableCell }, { accessorKey: "role", - header: "Role", + header: LL.Grids.colRole(), cell: SelectCell, meta: { selectOptions: ["Admin", "User", "Editor", "Viewer"] }, }, { accessorKey: "status", - header: "Status", + header: LL.Grids.colStatus(), cell: BadgeCell, meta: { badgeVariantMap: { @@ -379,7 +394,7 @@ function CrudGrid() { }, }, }, - { accessorKey: "createdAt", header: "Created", cell: DateCell }, + { accessorKey: "createdAt", header: LL.Grids.colCreated(), cell: DateCell }, { id: "actions", header: "", @@ -391,12 +406,14 @@ function CrudGrid() { return (
- Demo — in-memory data + {LL.Grids.demoInMemory()}
- {modified && Unsaved changes} + {modified && ( + {LL.Grids.unsavedChanges()} + )}
@@ -417,6 +434,7 @@ function CrudGrid() { /* Demo B — Batch Operations (Demo) */ /* ================================================================== */ function GlobalCrudGrid() { + const { LL } = useI18n(); const [data, setData] = useState(() => [...initialRecords]); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -459,18 +477,26 @@ function GlobalCrudGrid() { ), enableSorting: false, }, - { accessorKey: "firstName", header: "First Name", cell: EditableCell }, - { accessorKey: "lastName", header: "Last Name", cell: EditableCell }, - { accessorKey: "email", header: "Email", cell: EditableCell }, + { + accessorKey: "firstName", + header: LL.Grids.colFirstName(), + cell: EditableCell, + }, + { + accessorKey: "lastName", + header: LL.Grids.colLastName(), + cell: EditableCell, + }, + { accessorKey: "email", header: LL.Grids.colEmail(), cell: EditableCell }, { accessorKey: "role", - header: "Role", + header: LL.Grids.colRole(), cell: SelectCell, meta: { selectOptions: ["Admin", "User", "Editor", "Viewer"] }, }, { accessorKey: "status", - header: "Status", + header: LL.Grids.colStatus(), cell: BadgeCell, meta: { badgeVariantMap: { @@ -480,7 +506,7 @@ function GlobalCrudGrid() { }, }, }, - { accessorKey: "createdAt", header: "Created", cell: DateCell }, + { accessorKey: "createdAt", header: LL.Grids.colCreated(), cell: DateCell }, ]; const selectedCount = Object.keys(selectedIds).length; @@ -488,15 +514,15 @@ function GlobalCrudGrid() { return (
- Demo — in-memory data + {LL.Grids.demoInMemory()} {selectedCount > 0 && (
- {selectedCount} selected + {LL.Grids.selectedCount({ count: selectedCount })}
)} @@ -534,6 +560,7 @@ function GlobalCrudGrid() { /* Demo C — Tenants (Demo) */ /* ================================================================== */ function TenantsGrid() { + const { LL } = useI18n(); const groupedByPlan = ["Enterprise", "Pro", "Free"] as const; const tenantColumns: ColumnDef[] = [ @@ -553,10 +580,14 @@ function TenantsGrid() { ), enableSorting: false, }, - { accessorKey: "tenantName", header: "Tenant Name", cell: ReadOnlyCell }, + { + accessorKey: "tenantName", + header: LL.Grids.colTenantName(), + cell: ReadOnlyCell, + }, { accessorKey: "plan", - header: "Plan", + header: LL.Grids.colPlan(), cell: BadgeCell, meta: { badgeVariantMap: { @@ -568,17 +599,21 @@ function TenantsGrid() { }, { accessorKey: "usersCount", - header: "Users", + header: LL.Grids.colUsers(), cell: (props) => ( {(props.getValue() as number).toLocaleString()} ), }, - { accessorKey: "storageUsed", header: "Storage", cell: ReadOnlyCell }, + { + accessorKey: "storageUsed", + header: LL.Grids.colStorage(), + cell: ReadOnlyCell, + }, { accessorKey: "status", - header: "Status", + header: LL.Grids.colStatus(), cell: BadgeCell, meta: { badgeVariantMap: { @@ -589,8 +624,8 @@ function TenantsGrid() { }, }, }, - { accessorKey: "region", header: "Region", cell: ReadOnlyCell }, - { accessorKey: "createdAt", header: "Created", cell: DateCell }, + { accessorKey: "region", header: LL.Grids.colRegion(), cell: ReadOnlyCell }, + { accessorKey: "createdAt", header: LL.Grids.colCreated(), cell: DateCell }, { id: "actions", header: "", @@ -602,7 +637,7 @@ function TenantsGrid() { return (
- Demo — dummy data ({tenants.length} records) + {LL.Grids.demoDummyDataCount({ count: tenants.length })} {groupedByPlan.map((plan) => { const planTenants = tenants.filter((t) => t.plan === plan); @@ -628,7 +663,7 @@ function TenantsGrid() { renderSubRow={(tenant) => (

- Users in {tenant.tenantName} + {LL.Grids.usersIn({ name: tenant.tenantName })}

{Array.from( @@ -639,13 +674,15 @@ function TenantsGrid() { className="flex items-center gap-2 rounded bg-slate-700/50 px-2 py-1" >
- User {i + 1} + {LL.Grids.userN({ n: i + 1 })}
), )} {tenant.usersCount > 6 && ( - +{tenant.usersCount - 6} more + {LL.Grids.moreUsers({ + count: tenant.usersCount - 6, + })} )}
@@ -667,11 +704,12 @@ function TenantsGrid() { /* Demo D — Metrics (Demo) */ /* ================================================================== */ function ReadOnlyGrid() { + const { LL } = useI18n(); const columns: ColumnDef[] = [ - { accessorKey: "metric", header: "Metric", cell: ReadOnlyCell }, + { accessorKey: "metric", header: LL.Grids.colMetric(), cell: ReadOnlyCell }, { accessorKey: "value", - header: "Value", + header: LL.Grids.colValue(), cell: (props) => { const v = props.getValue() as number; return ( @@ -681,11 +719,11 @@ function ReadOnlyGrid() { ); }, }, - { accessorKey: "change", header: "Change", cell: DeltaCell }, - { accessorKey: "period", header: "Period", cell: ReadOnlyCell }, + { accessorKey: "change", header: LL.Grids.colChange(), cell: DeltaCell }, + { accessorKey: "period", header: LL.Grids.colPeriod(), cell: ReadOnlyCell }, { accessorKey: "category", - header: "Category", + header: LL.Grids.colCategory(), cell: BadgeCell, meta: { badgeVariantMap: { @@ -707,7 +745,7 @@ function ReadOnlyGrid() { return (
- Demo — dummy data + {LL.Grids.demoDummyData()} [] = [ @@ -744,20 +783,28 @@ function AdvancedGrid() { ), enableSorting: false, }, - { accessorKey: "name", header: "Task", cell: ReadOnlyCell }, - { accessorKey: "tags", header: "Tags", cell: TagsCell }, - { accessorKey: "assignedTo", header: "Assigned To", cell: AvatarCell }, - { accessorKey: "priority", header: "Priority", cell: PriorityCell }, - { accessorKey: "dueDate", header: "Due Date", cell: DateCell }, + { accessorKey: "name", header: LL.Grids.colTask(), cell: ReadOnlyCell }, + { accessorKey: "tags", header: LL.Grids.colTags(), cell: TagsCell }, + { + accessorKey: "assignedTo", + header: LL.Grids.colAssignedTo(), + cell: AvatarCell, + }, + { + accessorKey: "priority", + header: LL.Grids.colPriority(), + cell: PriorityCell, + }, + { accessorKey: "dueDate", header: LL.Grids.colDueDate(), cell: DateCell }, { accessorKey: "completedAt", - header: "Completed", + header: LL.Grids.colCompleted(), cell: (props) => { const val = props.getValue() as string | null; return val ? ( - Done + {LL.Grids.done()} ) : ( - Open + {LL.Grids.open()} ); }, }, @@ -766,7 +813,7 @@ function AdvancedGrid() { return (
- Demo — dummy data + {LL.Grids.demoDummyData()}

{task.name}

- Assigned to {task.assignedTo} · Priority: {task.priority}{" "} - · Due: {new Date(task.dueDate).toLocaleDateString()} + {LL.Grids.assignedToLabel({ name: task.assignedTo })} ·{" "} + {LL.Grids.priorityLabel({ value: task.priority })} ·{" "} + {LL.Grids.dueLabel({ + date: new Date(task.dueDate).toLocaleDateString(), + })} {isOverdue && ( - OVERDUE + + {LL.Grids.overdue()} + )}

diff --git a/apps/web/app/lib/auth/auth-client.tsx b/apps/web/app/lib/auth/auth-client.tsx index c853af0..1c7e03f 100644 --- a/apps/web/app/lib/auth/auth-client.tsx +++ b/apps/web/app/lib/auth/auth-client.tsx @@ -29,9 +29,12 @@ export function AuthClientProvider({ children: React.ReactNode; }) { const [client, setClient] = useState(() => { - if (typeof window !== "undefined" && window.location?.origin) { + if ( + typeof globalThis.location !== "undefined" && + globalThis.location?.origin + ) { return createAuthClient({ - baseURL: window.location.origin, + baseURL: globalThis.location.origin, plugins: [emailOTPClient()], }); } @@ -40,11 +43,14 @@ export function AuthClientProvider({ useEffect(() => { if (client != null) return; - if (typeof window !== "undefined" && window.location?.origin) { + if ( + typeof globalThis.location !== "undefined" && + globalThis.location?.origin + ) { try { setClient( createAuthClient({ - baseURL: window.location.origin, + baseURL: globalThis.location.origin, plugins: [emailOTPClient()], }), ); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 101af74..808e65f 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -15,42 +15,73 @@ import { TenantDashboardOnly } from "./components/tenant-dashboard-only"; import { TenantAdminOnly } from "./components/tenant-admin-only"; import { Card, CardHeader, CardTitle, CardContent } from "./components/ui/card"; import { Button } from "./components/ui/button"; +import { useI18n } from "./hooks/useI18n"; +import type { LucideIcon } from "lucide-react"; -const stats = [ - { label: "Total Records", value: "1,284", icon: Database, trend: "+12%" }, - { label: "Active Tenants", value: "47", icon: Building2, trend: "+3" }, - { label: "Users", value: "312", icon: Users, trend: "+8%" }, - { label: "API Endpoints", value: "24", icon: Globe, trend: "stable" }, -]; +interface StatItem { + label: string; + value: string; + icon: LucideIcon; + trend: string; +} -const features = [ - { - title: "Powerful Data Grids", - description: - "Editable, sortable, filterable tables for all your data. CRUD operations, bulk actions, nested rows, and multi-select — powered by TanStack Table.", - icon: LayoutGrid, - href: "/grids", - color: "from-blue-500 to-blue-600", - }, - { - title: "Rich Visualizations", - description: - "10+ chart types powered by Plotly.js with WebGL support. Line, bar, scatter, 3D surfaces, heatmaps, and more — all interactive and responsive.", - icon: BarChart3, - href: "/charts", - color: "from-emerald-500 to-emerald-600", - }, - { - title: "Multi-tenant Ready", - description: - "CRUD operations scoped per tenant with role-based read/write control. Manage tenants, members, and permissions from a single dashboard.", - icon: Building2, - href: "/tenant-dashboard", - color: "from-amber-500 to-orange-600", - }, -]; +interface FeatureItem { + title: string; + description: string; + icon: LucideIcon; + href: string; + color: string; +} export default function Home() { + const { LL } = useI18n(); + + const stats: StatItem[] = [ + { + label: LL.Home.totalRecords(), + value: "1,284", + icon: Database, + trend: "+12%", + }, + { + label: LL.Home.activeTenants(), + value: "47", + icon: Building2, + trend: "+3", + }, + { label: LL.Home.users(), value: "312", icon: Users, trend: "+8%" }, + { + label: LL.Home.apiEndpoints(), + value: "24", + icon: Globe, + trend: LL.Home.stable(), + }, + ]; + + const features: FeatureItem[] = [ + { + title: LL.Home.powerfulDataGrids(), + description: LL.Home.powerfulDataGridsDesc(), + icon: LayoutGrid, + href: "/grids", + color: "from-blue-500 to-blue-600", + }, + { + title: LL.Home.richVisualizations(), + description: LL.Home.richVisualizationsDesc(), + icon: BarChart3, + href: "/charts", + color: "from-emerald-500 to-emerald-600", + }, + { + title: LL.Home.multiTenantReady(), + description: LL.Home.multiTenantReadyDesc(), + icon: Building2, + href: "/tenant-dashboard", + color: "from-amber-500 to-orange-600", + }, + ]; + return (
{/* Hero */} @@ -60,25 +91,24 @@ export default function Home() { BE

- BE: Tech Stack + {LL.Home.heroTitle()}

- Full-stack boilerplate with multi-tenancy, authentication, and CRUD - operations. Explore the data grids and charting capabilities below. + {LL.Home.heroDescription()}

@@ -109,7 +139,7 @@ export default function Home() { {/* Feature Highlights */}

- Feature Highlights + {LL.Home.featureHighlights()}

{features.map((feature) => ( @@ -143,9 +173,11 @@ export default function Home() { >
-

Tenant Dashboard

+

+ {LL.Home.tenantDashboard()} +

- Manage platform tenants and subscriptions + {LL.Home.tenantDashboardDesc()}

@@ -158,9 +190,11 @@ export default function Home() { >
-

Manage Members

+

+ {LL.Home.manageMembersTitle()} +

- Add, remove, and manage tenant member roles + {LL.Home.manageMembersQuickDesc()}

diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx new file mode 100644 index 0000000..e4eb379 --- /dev/null +++ b/apps/web/app/privacy/page.tsx @@ -0,0 +1,264 @@ +"use client"; + +import Link from "next/link"; +import { useI18n } from "../hooks/useI18n"; + +export default function PrivacyPage() { + const { LL } = useI18n(); + + return ( +
+
+ + + + + {LL.Common.back()} + + +
+

+ {LL.Privacy.title()} +

+

+ {LL.Privacy.lastUpdated()} +

+ +
+ {/* ── What we collect ── */} +
+

+ {LL.Privacy.section1Title()} +

+

{LL.Privacy.section1Intro()}

+
    +
  • + {LL.Privacy.collectName()}{" "} + {LL.Privacy.collectNameDetail()} +
  • +
  • + {LL.Privacy.collectImage()}{" "} + {LL.Privacy.collectImageDetail()} +
  • +
  • + {LL.Privacy.collectPassword()}{" "} + {LL.Privacy.collectPasswordDetail()} +
  • +
  • + {LL.Privacy.collectOAuth()}{" "} + {LL.Privacy.collectOAuthDetail()} +
  • +
  • + {LL.Privacy.collectIp()}{" "} + {LL.Privacy.collectIpDetail()} +
  • +
  • + {LL.Privacy.collectConsent()}{" "} + {LL.Privacy.collectConsentDetail()} +
  • +
+
+ + {/* ── How we use it ── */} +
+

+ {LL.Privacy.section2Title()} +

+
    +
  • + {LL.Privacy.useAuth()}{" "} + {LL.Privacy.useAuthDetail()} +
  • +
  • + {LL.Privacy.useAccount()}{" "} + {LL.Privacy.useAccountDetail()} +
  • +
  • + {LL.Privacy.useEmail()}{" "} + {LL.Privacy.useEmailDetail()} +
  • +
  • + {LL.Privacy.useSecurity()}{" "} + {LL.Privacy.useSecurityDetail()} +
  • +
  • + {LL.Privacy.useDrive()}{" "} + {LL.Privacy.useDriveDetail()} +
  • +
+

{LL.Privacy.noSellData()}

+
+ + {/* ── Cookies ── */} +
+

+ {LL.Privacy.section3Title()} +

+

+ {LL.Privacy.cookiesIntro()}{" "} + {LL.Privacy.cookiesIntroStrict()} + {LL.Privacy.cookiesIntroSuffix()} +

+
+ + + + + + + + + + + + + + + + + + + + +
+ {LL.Privacy.cookieHeader()} + + {LL.Privacy.purposeHeader()} + + {LL.Privacy.expiryHeader()} +
+ {LL.Privacy.cookieSessionToken()} + + {LL.Privacy.cookieSessionPurpose()} + + {LL.Privacy.cookieSessionExpiry()} +
+ {LL.Privacy.cookieOAuthState()} + + {LL.Privacy.cookieOAuthPurpose()} + + {LL.Privacy.cookieOAuthExpiry()} +
+
+

{LL.Privacy.cookiesFootnote()}

+
+ + {/* ── Third parties ── */} +
+

+ {LL.Privacy.section4Title()} +

+
    +
  • + {LL.Privacy.thirdPartyGoogle()}{" "} + {LL.Privacy.thirdPartyGoogleDetail()}{" "} + + {LL.Privacy.googlePrivacyPolicy()} + + . +
  • +
  • + {LL.Privacy.thirdPartyAws()}{" "} + {LL.Privacy.thirdPartyAwsDetail()} +
  • +
  • + {LL.Privacy.thirdPartyRollbar()}{" "} + {LL.Privacy.thirdPartyRollbarDetail()} +
  • +
+
+ + {/* ── Security ── */} +
+

+ {LL.Privacy.section5Title()} +

+
    +
  • {LL.Privacy.protectPassword()}
  • +
  • {LL.Privacy.protectOAuth()}
  • +
  • {LL.Privacy.protectCookies()}
  • +
  • {LL.Privacy.protectLogs()}
  • +
  • {LL.Privacy.protectHttps()}
  • +
+
+ + {/* ── Your rights ── */} +
+

+ {LL.Privacy.section6Title()} +

+

{LL.Privacy.rightsIntro()}

+
    +
  • + {LL.Privacy.rightAccess()}{" "} + {LL.Privacy.rightAccessDetail()} +
  • +
  • + {LL.Privacy.rightRectify()}{" "} + {LL.Privacy.rightRectifyDetail()} +
  • +
  • + {LL.Privacy.rightDelete()}{" "} + {LL.Privacy.rightDeleteDetail()} +
  • +
  • + {LL.Privacy.rightWithdraw()}{" "} + {LL.Privacy.rightWithdrawDetail()} +
  • +
+

{LL.Privacy.rightsAuditNote()}

+
+ + {/* ── Retention ── */} +
+

+ {LL.Privacy.section7Title()} +

+
    +
  • {LL.Privacy.retainAccount()}
  • +
  • {LL.Privacy.retainSessions()}
  • +
  • {LL.Privacy.retainVerification()}
  • +
  • {LL.Privacy.retainDeletion()}
  • +
+
+ + {/* ── Contact ── */} +
+

+ {LL.Privacy.section8Title()} +

+

+ {LL.Privacy.contactText()}{" "} + + anns.shahbaz@binaryexports.com + + . +

+
+
+
+
+
+ ); +} diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index f2f1961..526299a 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { useAuthClient } from "../lib/auth/auth-client"; import { useI18n } from "../hooks/useI18n"; +import { trpc } from "@repo/trpc/client"; interface AccountInfo { id: string; @@ -21,6 +22,27 @@ export default function ProfilePage() { const [accounts, setAccounts] = useState([]); const [loadingAccounts, setLoadingAccounts] = useState(true); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(""); + const [nameError, setNameError] = useState(null); + const [nameLoading, setNameLoading] = useState(false); + const [nameMessage, setNameMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const [downloadingData, setDownloadingData] = useState(false); + const [downloadSuccess, setDownloadSuccess] = useState(false); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteConfirmEmail, setDeleteConfirmEmail] = useState(""); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + const utils = trpc.useUtils(); + const updateProfileMutation = trpc.gdpr.updateProfile.useMutation(); + const deleteAccountMutation = trpc.gdpr.deleteAccount.useMutation(); + const [changingPassword, setChangingPassword] = useState(false); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -161,6 +183,97 @@ export default function ProfilePage() { setVerificationSent(true); }; + const NAME_REGEX = /^[a-zA-Z\s'-]+$/; + + const validateName = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return LL.Errors.nameRequired({ label: LL.Forms.name() }); + if (trimmed.length < 2) + return LL.Errors.nameMinLength({ label: LL.Forms.name() }); + if (trimmed.length > 100) + return LL.Errors.nameInvalidChars({ label: LL.Forms.name() }); + if (!NAME_REGEX.test(trimmed)) + return LL.Errors.nameInvalidChars({ label: LL.Forms.name() }); + return null; + }; + + const handleUpdateName = async () => { + const error = validateName(nameValue); + if (error) { + setNameError(error); + return; + } + setNameLoading(true); + setNameMessage(null); + setNameError(null); + + try { + await updateProfileMutation.mutateAsync({ name: nameValue.trim() }); + + if (authClient) { + await authClient.updateUser({ name: nameValue.trim() }); + } + + setNameMessage({ type: "success", text: LL.Settings.nameUpdated() }); + setEditingName(false); + } catch (err) { + setNameMessage({ + type: "error", + text: + err instanceof Error ? err.message : LL.Settings.failedUpdateName(), + }); + } + setNameLoading(false); + }; + + const handleDownloadData = async () => { + setDownloadingData(true); + setDownloadSuccess(false); + try { + const data = await utils.gdpr.exportData.fetch(); + + await new Promise((r) => setTimeout(r, 600)); + + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "my-data-export.json"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + setDownloadSuccess(true); + setTimeout(() => setDownloadSuccess(false), 3000); + } catch { + // download failed silently + } + setDownloadingData(false); + }; + + const handleDeleteAccount = async () => { + if (!user?.email || deleteConfirmEmail !== user.email) return; + setDeleteLoading(true); + setDeleteError(null); + + try { + await deleteAccountMutation.mutateAsync({ + confirmation: deleteConfirmEmail, + }); + + await new Promise((r) => setTimeout(r, 5000)); + + window.location.href = "/sign-in"; + } catch (err) { + setDeleteError( + err instanceof Error ? err.message : LL.Settings.failedDeleteAccount(), + ); + setDeleteLoading(false); + } + }; + return (
@@ -522,6 +635,195 @@ export default function ProfilePage() {
)}
+ + {/* Edit Profile */} +
+

+ {LL.Settings.editProfile()} +

+ + {nameMessage && ( +
+ {nameMessage.text} +
+ )} + + {!editingName ? ( + + ) : ( +
+
+ + { + setNameValue(e.target.value); + if (nameError) setNameError(null); + }} + maxLength={100} + className={`w-full px-3 py-2 bg-slate-700 border rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm ${nameError ? "border-red-500" : "border-slate-600"}`} + placeholder={LL.Settings.fullNamePlaceholder()} + /> + {nameError && ( +

{nameError}

+ )} +

+ {LL.Settings.nameHint()} +

+
+
+ + +
+
+ )} +
+ + {/* Data Privacy & GDPR */} +
+

+ {LL.Settings.dataAndPrivacy()} +

+

+ {LL.Settings.dataPrivacyDescription()}{" "} + + {LL.Settings.privacyAndDataPolicy()} + + . +

+ +
+
+
+

+ {LL.Settings.downloadMyData()} +

+

+ {LL.Settings.downloadMyDataDescription()} +

+
+ +
+ +
+
+
+

+ {LL.Settings.deleteAccount()} +

+

+ {LL.Settings.deleteAccountDescription()} +

+
+ {!showDeleteConfirm && ( + + )} +
+ + {showDeleteConfirm && ( +
+

+ {LL.Settings.deleteConfirmPrefix()}{" "} + + {user.email} + {" "} + {LL.Settings.deleteConfirmSuffix()} +

+ setDeleteConfirmEmail(e.target.value)} + placeholder={LL.Settings.typeEmailToConfirm()} + className="w-full px-3 py-2 bg-slate-700 border border-red-600/50 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent text-sm mb-3" + /> + {deleteError && ( +

{deleteError}

+ )} +
+ + +
+
+ )} +
+
+
); diff --git a/apps/web/app/reset-password/page.tsx b/apps/web/app/reset-password/page.tsx index 7b60c44..f24071c 100644 --- a/apps/web/app/reset-password/page.tsx +++ b/apps/web/app/reset-password/page.tsx @@ -5,6 +5,21 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; import { useAuthClient } from "../lib/auth/auth-client"; import { useI18n } from "../hooks/useI18n"; +import type { TranslationFunctions } from "../../i18n/i18n-types"; + +function validatePassword( + value: string, + E: TranslationFunctions["Errors"], +): string | null { + if (!value) return E.passwordRequired(); + if (value.length < 8) return E.passwordMinLength(); + if (value.length > 128) return E.passwordMaxLength(); + if (!/[A-Z]/.test(value)) return E.passwordUppercase(); + if (!/[a-z]/.test(value)) return E.passwordLowercase(); + if (!/[0-9]/.test(value)) return E.passwordNumber(); + if (!/[^A-Za-z0-9]/.test(value)) return E.passwordSpecialChar(); + return null; +} function ResetPasswordContent() { const { LL } = useI18n(); @@ -19,8 +34,18 @@ function ResetPasswordContent() { const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); const [tokenError, setTokenError] = useState(false); + const clearFieldError = (field: string) => { + setFieldErrors((prev) => { + if (!(field in prev)) return prev; + const next = { ...prev }; + delete next[field]; + return next; + }); + }; + useEffect(() => { if (errorParam) { setTokenError(true); @@ -67,16 +92,20 @@ function ResetPasswordContent() { e.preventDefault(); if (!authClient) return; - if (newPassword !== confirmPassword) { - setError(LL.Errors.passwordsDoNotMatch()); - return; - } + const errors: Record = {}; - if (newPassword.length < 8) { - setError(LL.Errors.passwordMinLength()); - return; + const pwErr = validatePassword(newPassword, LL.Errors); + if (pwErr) errors.newPassword = pwErr; + + if (!confirmPassword) { + errors.confirmPassword = LL.Errors.passwordRequired(); + } else if (newPassword !== confirmPassword) { + errors.confirmPassword = LL.Errors.passwordsDoNotMatch(); } + setFieldErrors(errors); + if (Object.keys(errors).length > 0) return; + setLoading(true); setError(null); @@ -154,13 +183,24 @@ function ResetPasswordContent() { type="password" id="new-password" value={newPassword} - onChange={(e) => setNewPassword(e.target.value)} + onChange={(e) => { + setNewPassword(e.target.value); + if (fieldErrors.newPassword) clearFieldError("newPassword"); + }} required - minLength={8} maxLength={128} placeholder={LL.Forms.passwordPlaceholder()} - className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${fieldErrors.newPassword ? "border-red-400" : "border-gray-300"}`} /> + {fieldErrors.newPassword ? ( +

+ {fieldErrors.newPassword} +

+ ) : ( +

+ {LL.Auth.passwordMinChars()} +

+ )}