From 9d3424bf2e0ff8eea37a941a557fc917415f2da0 Mon Sep 17 00:00:00 2001 From: Anns Shahbaz Date: Fri, 20 Feb 2026 09:36:35 +0500 Subject: [PATCH 01/11] GDPR itr 1 --- apps/api/src/app.module.ts | 2 + apps/api/src/modules/auth/auth.ts | 39 ++- apps/api/src/modules/email/email.service.ts | 11 +- apps/api/src/modules/gdpr/gdpr.module.ts | 26 ++ apps/api/src/modules/gdpr/gdpr.router.ts | 101 +++++++ apps/api/src/modules/gdpr/gdpr.schema.ts | 91 +++++++ apps/api/src/modules/gdpr/gdpr.service.ts | 181 +++++++++++++ ...r-audit-log.prisma-repository.interface.ts | 19 ++ .../gdpr-audit-log.prisma-repository.ts | 45 ++++ apps/api/src/trpc/trpc-error-formatter.ts | 5 - apps/web/app/profile/page.tsx | 250 ++++++++++++++++++ apps/web/app/sign-up/page.tsx | 26 +- .../migration.sql | 19 ++ .../prisma-db/migrations/migration_lock.toml | 4 +- packages/prisma-db/schema.prisma | 15 ++ packages/trpc/src/server/server.ts | 119 +++++++++ 16 files changed, 942 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/modules/gdpr/gdpr.module.ts create mode 100644 apps/api/src/modules/gdpr/gdpr.router.ts create mode 100644 apps/api/src/modules/gdpr/gdpr.schema.ts create mode 100644 apps/api/src/modules/gdpr/gdpr.service.ts create mode 100644 apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.interface.ts create mode 100644 apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.ts create mode 100644 packages/prisma-db/migrations/20260220041033_gdpr_compliance/migration.sql 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/modules/auth/auth.ts b/apps/api/src/modules/auth/auth.ts index f2fc0d2..3883c55 100644 --- a/apps/api/src/modules/auth/auth.ts +++ b/apps/api/src/modules/auth/auth.ts @@ -10,9 +10,11 @@ import { NodeEnvironment, parseNodeEnvironment, } from '../../lib/types/environment.type'; +import { Logger } from '@repo/utils-core'; + +const prisma = new PrismaClient(); const createDatabaseAdapter = () => { - const prisma = new PrismaClient(); return prismaAdapter(prisma, { provider: 'postgresql', }); @@ -30,6 +32,41 @@ 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 prisma.user.update({ + where: { id: user.id }, + data: { + consentGiven: true, + consentAt: new Date(), + consentIp: ipAddress, + }, + }); + await prisma.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); + } + }, + }, + }, + }, emailAndPassword: { enabled: true, requireEmailVerification: !isDevelopment, diff --git a/apps/api/src/modules/email/email.service.ts b/apps/api/src/modules/email/email.service.ts index daa7644..7a92694 100644 --- a/apps/api/src/modules/email/email.service.ts +++ b/apps/api/src/modules/email/email.service.ts @@ -16,6 +16,13 @@ import { renderEmail } from './email.renderer'; import { ResendProvider } from './resend.provider'; import { AwsSesProvider } from './aws-ses.provider'; +function maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!local || !domain) return '***'; + const visible = local.length <= 2 ? local[0] : local.slice(0, 2); + return `${visible}***@${domain}`; +} + @Injectable() export class EmailService { constructor( @@ -121,11 +128,11 @@ export class EmailService { } Logger.instance.info( - `Email (${templateName}) sent successfully to ${to} via ${provider}. ID: ${messageId}`, + `Email (${templateName}) sent successfully to ${maskEmail(to)} via ${provider}. ID: ${messageId}`, ); } catch (error) { Logger.instance.critical( - `Failed to send email (${templateName}) to ${to} via ${provider}:`, + `Failed to send email (${templateName}) to ${maskEmail(to)} via ${provider}:`, 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..e370696 --- /dev/null +++ b/apps/api/src/modules/gdpr/gdpr.module.ts @@ -0,0 +1,26 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; +import { GdprService } from './gdpr.service'; +import { GdprRouter } from './gdpr.router'; +import { GdprAuditLogPrismaRepository } from './repositories/prisma/gdpr-audit-log.prisma-repository'; +import { Logger } from '@repo/utils-core'; + +@Module({ + imports: [PrismaModule, AuthModule], + providers: [GdprService, GdprRouter, GdprAuditLogPrismaRepository], + exports: [GdprService], +}) +export class GdprModule implements OnModuleInit { + constructor(private readonly gdprService: GdprService) {} + + async onModuleInit() { + try { + await this.gdprService.cleanupExpiredVerifications(); + } catch (err) { + Logger.instance + .withContext('GdprModule') + .critical('Failed to clean up expired verifications on startup', err); + } + } +} 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..f9ebfe9 --- /dev/null +++ b/apps/api/src/modules/gdpr/gdpr.router.ts @@ -0,0 +1,101 @@ +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) {} + + 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; + } + + @Query({ output: ZGdprMyDataResponse }) + async myData(@Ctx() ctx: AppContextType): Promise { + const user = ctx.user!; + return this.gdprService.getMyData( + user.id, + user.email, + this.extractIp(ctx), + ); + } + + @Query({ output: ZGdprMyDataResponse }) + async exportData(@Ctx() ctx: AppContextType): Promise { + const user = ctx.user!; + return this.gdprService.getMyData( + user.id, + user.email, + 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( + user.id, + user.email, + this.extractIp(ctx), + ); + + return { success: true }; + } +} 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..39466b7 --- /dev/null +++ b/apps/api/src/modules/gdpr/gdpr.schema.ts @@ -0,0 +1,91 @@ +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({ + id: z.string(), + 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({ + id: z.string(), + providerId: z.string(), + accountId: z.string(), + scope: z.string().nullable(), + createdAt: z.date(), + }), + ), + sessions: z.array( + z.object({ + id: z.string(), + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), + createdAt: z.date(), + expiresAt: z.date(), + }), + ), + profile: z + .object({ + selectedTenantId: z.string().nullable(), + createdAt: z.date(), + }) + .nullable(), + tenantMemberships: z.array( + z.object({ + id: z.string(), + tenantId: z.string(), + role: z.string(), + createdAt: z.date(), + }), + ), +}); + +// ─── Update Profile (Right of Rectification) ─────────────────── + +export const ZGdprUpdateProfileRequest = ZBaseRequest.extend({ + name: z.string().min(1).max(255).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..c934b9c --- /dev/null +++ b/apps/api/src/modules/gdpr/gdpr.service.ts @@ -0,0 +1,181 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { GdprAuditLogPrismaRepository } from './repositories/prisma/gdpr-audit-log.prisma-repository'; +import { Logger } from '@repo/utils-core'; +import type { + TGdprMyDataResponse, + TGdprUpdateProfileResponse, +} from './gdpr.schema'; + +@Injectable() +export class GdprService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLogRepository: GdprAuditLogPrismaRepository, + ) {} + + private get logger() { + return Logger.instance.withContext(GdprService.name); + } + + async getMyData( + userId: string, + userEmail: string, + ipAddress: string | null, + ): Promise { + const user = await this.prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + emailVerified: true, + image: true, + consentGiven: true, + consentAt: true, + createdAt: true, + updatedAt: true, + }, + }); + + const accounts = await this.prisma.account.findMany({ + where: { userId }, + select: { + id: true, + providerId: true, + accountId: true, + scope: true, + createdAt: true, + }, + }); + + const sessions = await this.prisma.session.findMany({ + where: { userId }, + select: { + id: true, + ipAddress: true, + userAgent: true, + createdAt: true, + expiresAt: true, + }, + }); + + const profile = await this.prisma.userProfile.findUnique({ + where: { userId }, + select: { + selectedTenantId: true, + createdAt: true, + }, + }); + + const tenantMemberships = await this.prisma.tenantMembership.findMany({ + where: { email: userEmail.trim().toLowerCase() }, + select: { + id: true, + tenantId: true, + role: true, + 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, + tenantMemberships, + }; + } + + 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.prisma.user.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( + userId: string, + userEmail: string, + ipAddress: string | null, + ): Promise { + const normalizedEmail = userEmail.trim().toLowerCase(); + + await this.auditLogRepository.create({ + data: { + userId, + action: 'DATA_DELETION', + details: 'Account deletion requested and executed', + ipAddress, + }, + }); + + await this.prisma.tenantMembership.deleteMany({ + where: { email: normalizedEmail }, + }); + + await this.prisma.verification.deleteMany({ + where: { identifier: normalizedEmail }, + }); + + // Cascade handles: Session, Account, UserProfile + await this.prisma.user.delete({ + where: { id: userId }, + }); + + this.logger.info(`Account deleted for user ${userId}`); + } + + async cleanupExpiredVerifications(): Promise { + const result = await this.prisma.verification.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + + if (result.count > 0) { + this.logger.info( + `Cleaned up ${result.count} expired verification tokens`, + ); + } + + return result.count; + } +} 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..1e79f95 --- /dev/null +++ b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.interface.ts @@ -0,0 +1,19 @@ +import { Prisma } from '@repo/prisma-db'; + +export interface IGdprAuditLogPrismaRepository { + create( + args: Prisma.GdprAuditLogCreateArgs, + ): Promise< + Prisma.GdprAuditLogGetPayload + >; + + findMany( + args?: Prisma.GdprAuditLogFindManyArgs, + ): Promise< + Prisma.GdprAuditLogGetPayload[] + >; + + deleteMany( + args?: Prisma.GdprAuditLogDeleteManyArgs, + ): 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..4d4d1cd --- /dev/null +++ b/apps/api/src/modules/gdpr/repositories/prisma/gdpr-audit-log.prisma-repository.ts @@ -0,0 +1,45 @@ +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< + Prisma.GdprAuditLogGetPayload + > { + return this.delegate.create(args); + } + + findMany( + args?: Prisma.GdprAuditLogFindManyArgs, + ): Promise< + Prisma.GdprAuditLogGetPayload[] + > { + return this.delegate.findMany(args); + } + + deleteMany( + args?: Prisma.GdprAuditLogDeleteManyArgs, + ): Promise { + return this.delegate.deleteMany(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/profile/page.tsx b/apps/web/app/profile/page.tsx index f2f1961..013e75e 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -2,8 +2,10 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; import { useAuthClient } from "../lib/auth/auth-client"; import { useI18n } from "../hooks/useI18n"; +import { trpc } from "@repo/trpc/client"; interface AccountInfo { id: string; @@ -14,6 +16,7 @@ interface AccountInfo { export default function ProfilePage() { const { LL } = useI18n(); const authClient = useAuthClient(); + const router = useRouter(); const sessionResult = authClient?.useSession?.(); const session = sessionResult?.data; const user = session?.user; @@ -21,6 +24,24 @@ export default function ProfilePage() { const [accounts, setAccounts] = useState([]); const [loadingAccounts, setLoadingAccounts] = useState(true); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(""); + const [nameLoading, setNameLoading] = useState(false); + const [nameMessage, setNameMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + + const [downloadingData, setDownloadingData] = useState(false); + + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteConfirmEmail, setDeleteConfirmEmail] = useState(""); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + 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 +182,70 @@ export default function ProfilePage() { setVerificationSent(true); }; + const handleUpdateName = async () => { + if (!nameValue.trim()) return; + setNameLoading(true); + setNameMessage(null); + + try { + await updateProfileMutation.mutateAsync({ name: nameValue.trim() }); + setNameMessage({ type: "success", text: "Name updated successfully" }); + setEditingName(false); + } catch (err) { + setNameMessage({ + type: "error", + text: err instanceof Error ? err.message : "Failed to update name", + }); + } + setNameLoading(false); + }; + + const handleDownloadData = async () => { + setDownloadingData(true); + try { + const response = await fetch( + `${typeof window !== "undefined" ? window.location.origin : ""}/api/trpc/gdpr.exportData`, + { credentials: "include" }, + ); + const data = await response.json(); + 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); + } 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, + }); + if (authClient) { + await authClient.signOut(); + } + router.replace("/sign-in"); + } catch (err) { + setDeleteError( + err instanceof Error ? err.message : "Failed to delete account", + ); + setDeleteLoading(false); + } + }; + return (
@@ -522,6 +607,171 @@ export default function ProfilePage() {
)} + + {/* Edit Profile */} +
+

+ Edit Profile +

+ + {nameMessage && ( +
+ {nameMessage.text} +
+ )} + + {!editingName ? ( + + ) : ( +
+
+ + setNameValue(e.target.value)} + className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + placeholder="Your full name" + /> +
+
+ + +
+
+ )} +
+ + {/* Data Privacy & GDPR */} +
+

+ Data & Privacy +

+

+ You have the right to access, export, and delete your personal data at any time. +

+ +
+
+
+

+ Download My Data +

+

+ Export all your personal data as a JSON file +

+
+ +
+ +
+
+
+

+ Delete Account +

+

+ Permanently delete your account and all associated data +

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

+ This action is irreversible. Type your email address{" "} + + {user.email} + {" "} + to confirm. +

+ setDeleteConfirmEmail(e.target.value)} + placeholder="Type your email to confirm" + 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/sign-up/page.tsx b/apps/web/app/sign-up/page.tsx index 4d6ac68..1da5b9d 100644 --- a/apps/web/app/sign-up/page.tsx +++ b/apps/web/app/sign-up/page.tsx @@ -63,6 +63,7 @@ export default function SignUpPage() { const [lastName, setLastName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [consent, setConsent] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); @@ -93,6 +94,8 @@ export default function SignUpPage() { const pwErr = validatePassword(password, E); if (pwErr) errors.password = pwErr; + if (!consent) errors.consent = "You must consent to data processing to create an account"; + setFieldErrors(errors); return Object.keys(errors).length === 0; }; @@ -317,9 +320,30 @@ export default function SignUpPage() {

)} +
+ + {fieldErrors.consent && ( +

+ {fieldErrors.consent} +

+ )} +
diff --git a/apps/web/app/sign-in/page.tsx b/apps/web/app/sign-in/page.tsx index 77fb036..fea1c0d 100644 --- a/apps/web/app/sign-in/page.tsx +++ b/apps/web/app/sign-in/page.tsx @@ -246,6 +246,10 @@ function SignInContent() { {view === "choose" && (
+

+ By signing in, you agree to our privacy policy and consent to the processing of your personal data. You can withdraw consent at any time from your profile settings. +

+
@@ -722,7 +723,8 @@ export default function ProfilePage() { Data & Privacy

- You have the right to access, export, and delete your personal data at any time. + You have the right to access, export, and delete your personal data + at any time.

diff --git a/apps/web/app/sign-in/page.tsx b/apps/web/app/sign-in/page.tsx index fea1c0d..5b543bc 100644 --- a/apps/web/app/sign-in/page.tsx +++ b/apps/web/app/sign-in/page.tsx @@ -247,7 +247,9 @@ function SignInContent() { {view === "choose" && (

- By signing in, you agree to our privacy policy and consent to the processing of your personal data. You can withdraw consent at any time from your profile settings. + By signing in, you agree to our privacy policy and consent to + the processing of your personal data. You can withdraw consent + at any time from your profile settings.

+
+
+ ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 75b4770..03c0cab 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -10,6 +10,7 @@ import { import { LoggerProvider } from "@repo/ui/logger-provider"; import { AuthClientProvider } from "./lib/auth/auth-client"; import { AppShell } from "./components/app-shell"; +import { CookieBanner } from "./components/cookie-banner"; import React from "react"; export const metadata: Metadata = { @@ -34,6 +35,7 @@ export default function RootLayout({ )} > {children} + diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx new file mode 100644 index 0000000..18bb8a6 --- /dev/null +++ b/apps/web/app/privacy/page.tsx @@ -0,0 +1,282 @@ +import Link from "next/link"; + +export const metadata = { + title: "Privacy & Data Policy", +}; + +export default function PrivacyPage() { + return ( +
+
+ + + + + Back + + +
+

+ Privacy & Data Policy +

+

+ Last updated: February 20, 2026 +

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

+ 1. What data we collect +

+

When you create an account we store:

+
    +
  • + Name and email — provided by you or + your Google account. +
  • +
  • + Profile picture URL — from Google if + you sign in with Google. +
  • +
  • + Password — if you use email/password + sign-in. Stored encrypted; we never see or store it in plain + text. +
  • +
  • + OAuth tokens — if you sign in with + Google. Stored encrypted. Used to maintain your Google session + and access Google Drive on your behalf if you granted those + permissions. +
  • +
  • + IP address and user agent — recorded + per session for security and audit purposes. +
  • +
  • + Consent timestamp and IP — recorded + when you agree to this policy. +
  • +
+
+ + {/* ── How we use it ── */} +
+

+ 2. How we use your data +

+
    +
  • + Authentication — to sign you in, verify + your email, and manage your sessions. +
  • +
  • + Account management — to display your + profile and let you update or delete it. +
  • +
  • + Transactional emails — to send OTPs, + verification links, and password reset links. We never send + marketing email. +
  • +
  • + Security — to detect suspicious + activity and maintain audit logs. +
  • +
  • + Google Drive — if you granted Drive + scopes, to read or create files in your Drive on your behalf. + We do not access Drive without your explicit scope grant. +
  • +
+

+ We do not sell, share, or rent your personal data to third + parties. We do not use your data for advertising or profiling. +

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

+ 3. Cookies +

+

+ We use strictly necessary cookies only. No + analytics, advertising, or tracking cookies. +

+
+ + + + + + + + + + + + + + + + + + + + +
Cookie + Purpose + Expiry
+ Session Token + + Keeps you signed in across visits + 7 days
+ OAuth State + + Temporary token to secure the Google sign-in flow + 3 days
+
+

+ These cookies are required for the application to function. They + cannot be used to track you across other websites. +

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

+ 4. Third-party services +

+
    +
  • + Google (OAuth) — for sign-in and Drive + access. Subject to{" "} + + Google's Privacy Policy + + . +
  • +
  • + Amazon Web Services (SES) — to deliver + transactional emails. AWS processes recipient email addresses + solely for delivery. +
  • +
  • + Rollbar — for error monitoring. + Receives error messages and stack traces only; no user PII is + intentionally sent. +
  • +
+
+ + {/* ── Security ── */} +
+

+ 5. How we protect your data +

+
    +
  • + Passwords are stored encrypted and can never be read back. +
  • +
  • OAuth tokens are encrypted at rest on our servers.
  • +
  • Session cookies are secured and signed.
  • +
  • Email addresses are not stored in application logs.
  • +
  • All communication is over HTTPS in production.
  • +
+
+ + {/* ── Your rights ── */} +
+

+ 6. Your rights +

+

From your profile page you can:

+
    +
  • + Access — download all personal data we + hold about you as a JSON file. +
  • +
  • + Rectify — update your name and profile + picture. +
  • +
  • + Delete — permanently delete your + account and all associated data (sessions, OAuth tokens, + tenant memberships, verification tokens). This action is + irreversible. +
  • +
  • + Withdraw consent — by deleting your + account. +
  • +
+

+ All data subject actions are recorded in an internal audit log. +

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

+ 7. Data retention +

+
    +
  • + Account data is kept for as long as your account exists. +
  • +
  • Sessions expire after 7 days.
  • +
  • + Verification tokens (email, OTP, password reset) are + automatically purged after expiry. +
  • +
  • + When you delete your account, all personal data is permanently + removed. GDPR audit log entries (which contain only your user + ID and action type) are retained for legal accountability. +
  • +
+
+ + {/* ── Contact ── */} +
+

+ 8. Contact +

+

+ For privacy-related questions or requests, email us at{" "} + + anns.shahbaz@binaryexports.com + + . +

+
+
+
+
+
+ ); +} diff --git a/apps/web/app/profile/page.tsx b/apps/web/app/profile/page.tsx index ee1674d..a2eea20 100644 --- a/apps/web/app/profile/page.tsx +++ b/apps/web/app/profile/page.tsx @@ -722,7 +722,15 @@ export default function ProfilePage() {

You have the right to access, export, and delete your personal data - at any time. + at any time. Read our{" "} + + Privacy & Data Policy + + .

diff --git a/apps/web/app/sign-in/page.tsx b/apps/web/app/sign-in/page.tsx index 5b543bc..adef78f 100644 --- a/apps/web/app/sign-in/page.tsx +++ b/apps/web/app/sign-in/page.tsx @@ -247,9 +247,15 @@ function SignInContent() { {view === "choose" && (

- By signing in, you agree to our privacy policy and consent to - the processing of your personal data. You can withdraw consent - at any time from your profile settings. + By signing in, you agree to our{" "} + + Privacy & Data Policy + {" "} + and consent to the processing of your personal data.

-

User ID

+

{LL.Auth.userId()}

{session.user.id}

@@ -310,16 +310,16 @@ export default function AuthDemoClient() { {authStep === "choose" && ( <>

- By signing in, you agree to our{" "} + {LL.Auth.signInConsentPrefix()}{" "} - Privacy & Data Policy + {LL.Settings.privacyAndDataPolicy()} {" "} - and consent to the processing of your personal data. + {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/layout.tsx b/apps/web/app/layout.tsx index 03c0cab..75b4770 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -10,7 +10,6 @@ import { import { LoggerProvider } from "@repo/ui/logger-provider"; import { AuthClientProvider } from "./lib/auth/auth-client"; import { AppShell } from "./components/app-shell"; -import { CookieBanner } from "./components/cookie-banner"; import React from "react"; export const metadata: Metadata = { @@ -35,7 +34,6 @@ export default function RootLayout({ )} > {children} - 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 index 18bb8a6..e4eb379 100644 --- a/apps/web/app/privacy/page.tsx +++ b/apps/web/app/privacy/page.tsx @@ -1,10 +1,11 @@ -import Link from "next/link"; +"use client"; -export const metadata = { - title: "Privacy & Data Policy", -}; +import Link from "next/link"; +import { useI18n } from "../hooks/useI18n"; export default function PrivacyPage() { + const { LL } = useI18n(); + return (
@@ -25,51 +26,48 @@ export default function PrivacyPage() { d="M15 19l-7-7 7-7" /> - Back + {LL.Common.back()}

- Privacy & Data Policy + {LL.Privacy.title()}

- Last updated: February 20, 2026 + {LL.Privacy.lastUpdated()}

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

- 1. What data we collect + {LL.Privacy.section1Title()}

-

When you create an account we store:

+

{LL.Privacy.section1Intro()}

  • - Name and email — provided by you or - your Google account. + {LL.Privacy.collectName()}{" "} + {LL.Privacy.collectNameDetail()}
  • - Profile picture URL — from Google if - you sign in with Google. + {LL.Privacy.collectImage()}{" "} + {LL.Privacy.collectImageDetail()}
  • - Password — if you use email/password - sign-in. Stored encrypted; we never see or store it in plain - text. + {LL.Privacy.collectPassword()}{" "} + {LL.Privacy.collectPasswordDetail()}
  • - OAuth tokens — if you sign in with - Google. Stored encrypted. Used to maintain your Google session - and access Google Drive on your behalf if you granted those - permissions. + {LL.Privacy.collectOAuth()}{" "} + {LL.Privacy.collectOAuthDetail()}
  • - IP address and user agent — recorded - per session for security and audit purposes. + {LL.Privacy.collectIp()}{" "} + {LL.Privacy.collectIpDetail()}
  • - Consent timestamp and IP — recorded - when you agree to this policy. + {LL.Privacy.collectConsent()}{" "} + {LL.Privacy.collectConsentDetail()}
@@ -77,114 +75,113 @@ export default function PrivacyPage() { {/* ── How we use it ── */}

- 2. How we use your data + {LL.Privacy.section2Title()}

  • - Authentication — to sign you in, verify - your email, and manage your sessions. + {LL.Privacy.useAuth()}{" "} + {LL.Privacy.useAuthDetail()}
  • - Account management — to display your - profile and let you update or delete it. + {LL.Privacy.useAccount()}{" "} + {LL.Privacy.useAccountDetail()}
  • - Transactional emails — to send OTPs, - verification links, and password reset links. We never send - marketing email. + {LL.Privacy.useEmail()}{" "} + {LL.Privacy.useEmailDetail()}
  • - Security — to detect suspicious - activity and maintain audit logs. + {LL.Privacy.useSecurity()}{" "} + {LL.Privacy.useSecurityDetail()}
  • - Google Drive — if you granted Drive - scopes, to read or create files in your Drive on your behalf. - We do not access Drive without your explicit scope grant. + {LL.Privacy.useDrive()}{" "} + {LL.Privacy.useDriveDetail()}
-

- We do not sell, share, or rent your personal data to third - parties. We do not use your data for advertising or profiling. -

+

{LL.Privacy.noSellData()}

{/* ── Cookies ── */}

- 3. Cookies + {LL.Privacy.section3Title()}

- We use strictly necessary cookies only. No - analytics, advertising, or tracking cookies. + {LL.Privacy.cookiesIntro()}{" "} + {LL.Privacy.cookiesIntroStrict()} + {LL.Privacy.cookiesIntroSuffix()}

- + + - + - + -
Cookie - Purpose + {LL.Privacy.cookieHeader()} + + {LL.Privacy.purposeHeader()} + + {LL.Privacy.expiryHeader()} Expiry
- Session Token + {LL.Privacy.cookieSessionToken()} - Keeps you signed in across visits + {LL.Privacy.cookieSessionPurpose()} + + {LL.Privacy.cookieSessionExpiry()} 7 days
- OAuth State + {LL.Privacy.cookieOAuthState()} + + {LL.Privacy.cookieOAuthPurpose()} - Temporary token to secure the Google sign-in flow + {LL.Privacy.cookieOAuthExpiry()} 3 days
-

- These cookies are required for the application to function. They - cannot be used to track you across other websites. -

+

{LL.Privacy.cookiesFootnote()}

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

- 4. Third-party services + {LL.Privacy.section4Title()}

  • - Google (OAuth) — for sign-in and Drive - access. Subject to{" "} + {LL.Privacy.thirdPartyGoogle()}{" "} + {LL.Privacy.thirdPartyGoogleDetail()}{" "} - Google's Privacy Policy + {LL.Privacy.googlePrivacyPolicy()} .
  • - Amazon Web Services (SES) — to deliver - transactional emails. AWS processes recipient email addresses - solely for delivery. + {LL.Privacy.thirdPartyAws()}{" "} + {LL.Privacy.thirdPartyAwsDetail()}
  • - Rollbar — for error monitoring. - Receives error messages and stack traces only; no user PII is - intentionally sent. + {LL.Privacy.thirdPartyRollbar()}{" "} + {LL.Privacy.thirdPartyRollbarDetail()}
@@ -192,79 +189,64 @@ export default function PrivacyPage() { {/* ── Security ── */}

- 5. How we protect your data + {LL.Privacy.section5Title()}

    -
  • - Passwords are stored encrypted and can never be read back. -
  • -
  • OAuth tokens are encrypted at rest on our servers.
  • -
  • Session cookies are secured and signed.
  • -
  • Email addresses are not stored in application logs.
  • -
  • All communication is over HTTPS in production.
  • +
  • {LL.Privacy.protectPassword()}
  • +
  • {LL.Privacy.protectOAuth()}
  • +
  • {LL.Privacy.protectCookies()}
  • +
  • {LL.Privacy.protectLogs()}
  • +
  • {LL.Privacy.protectHttps()}
{/* ── Your rights ── */}

- 6. Your rights + {LL.Privacy.section6Title()}

-

From your profile page you can:

+

{LL.Privacy.rightsIntro()}

  • - Access — download all personal data we - hold about you as a JSON file. + {LL.Privacy.rightAccess()}{" "} + {LL.Privacy.rightAccessDetail()}
  • - Rectify — update your name and profile - picture. + {LL.Privacy.rightRectify()}{" "} + {LL.Privacy.rightRectifyDetail()}
  • - Delete — permanently delete your - account and all associated data (sessions, OAuth tokens, - tenant memberships, verification tokens). This action is - irreversible. + {LL.Privacy.rightDelete()}{" "} + {LL.Privacy.rightDeleteDetail()}
  • - Withdraw consent — by deleting your - account. + {LL.Privacy.rightWithdraw()}{" "} + {LL.Privacy.rightWithdrawDetail()}
-

- All data subject actions are recorded in an internal audit log. -

+

{LL.Privacy.rightsAuditNote()}

{/* ── Retention ── */}

- 7. Data retention + {LL.Privacy.section7Title()}

    -
  • - Account data is kept for as long as your account exists. -
  • -
  • Sessions expire after 7 days.
  • -
  • - Verification tokens (email, OTP, password reset) are - automatically purged after expiry. -
  • -
  • - When you delete your account, all personal data is permanently - removed. GDPR audit log entries (which contain only your user - ID and action type) are retained for legal accountability. -
  • +
  • {LL.Privacy.retainAccount()}
  • +
  • {LL.Privacy.retainSessions()}
  • +
  • {LL.Privacy.retainVerification()}
  • +
  • {LL.Privacy.retainDeletion()}
{/* ── Contact ── */}

- 8. Contact + {LL.Privacy.section8Title()}

- For privacy-related questions or requests, email us at{" "} + {LL.Privacy.contactText()}{" "} { const trimmed = value.trim(); - if (!trimmed) return "Name is required"; - if (trimmed.length < 2) return "Name must be at least 2 characters"; - if (trimmed.length > 100) return "Name must be at most 100 characters"; + 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 "Name can only contain letters, spaces, hyphens, and apostrophes"; + return LL.Errors.nameInvalidChars({ label: LL.Forms.name() }); return null; }; @@ -212,12 +214,13 @@ export default function ProfilePage() { await authClient.updateUser({ name: nameValue.trim() }); } - setNameMessage({ type: "success", text: "Name updated successfully" }); + setNameMessage({ type: "success", text: LL.Settings.nameUpdated() }); setEditingName(false); } catch (err) { setNameMessage({ type: "error", - text: err instanceof Error ? err.message : "Failed to update name", + text: + err instanceof Error ? err.message : LL.Settings.failedUpdateName(), }); } setNameLoading(false); @@ -265,7 +268,7 @@ export default function ProfilePage() { window.location.href = "/sign-in"; } catch (err) { setDeleteError( - err instanceof Error ? err.message : "Failed to delete account", + err instanceof Error ? err.message : LL.Settings.failedDeleteAccount(), ); setDeleteLoading(false); } @@ -636,7 +639,7 @@ export default function ProfilePage() { {/* Edit Profile */}

- Edit Profile + {LL.Settings.editProfile()}

{nameMessage && ( @@ -661,7 +664,7 @@ export default function ProfilePage() { }} className="px-4 py-2 text-sm font-medium text-blue-400 border border-blue-500/50 rounded-lg hover:bg-blue-500/10 transition-colors" > - Edit Name + {LL.Settings.editName()} ) : (
@@ -670,7 +673,7 @@ export default function ProfilePage() { htmlFor="edit-name" className="block text-sm text-slate-400 mb-1" > - Full Name + {LL.Settings.fullName()} {nameError && (

{nameError}

)}

- 2-100 characters. Letters, spaces, hyphens, and apostrophes - only. + {LL.Settings.nameHint()}

@@ -698,7 +700,7 @@ export default function ProfilePage() { disabled={nameLoading || !nameValue.trim()} className="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50" > - {nameLoading ? "Saving..." : "Save"} + {nameLoading ? LL.Common.saving() : LL.Common.save()}
@@ -760,10 +761,10 @@ export default function ProfilePage() {

- Delete Account + {LL.Settings.deleteAccount()}

- Permanently delete your account and all associated data + {LL.Settings.deleteAccountDescription()}

{!showDeleteConfirm && ( @@ -771,7 +772,7 @@ export default function ProfilePage() { onClick={() => setShowDeleteConfirm(true)} className="px-4 py-2 text-sm font-medium text-red-400 border border-red-500/50 rounded-lg hover:bg-red-500/10 transition-colors" > - Delete + {LL.Common.delete()} )}
@@ -779,17 +780,17 @@ export default function ProfilePage() { {showDeleteConfirm && (

- This action is irreversible. Type your email address{" "} + {LL.Settings.deleteConfirmPrefix()}{" "} {user.email} {" "} - to confirm. + {LL.Settings.deleteConfirmSuffix()}

setDeleteConfirmEmail(e.target.value)} - placeholder="Type your email to confirm" + 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 && ( @@ -804,8 +805,8 @@ export default function ProfilePage() { className="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > {deleteLoading - ? "Deleting..." - : "Permanently Delete Account"} + ? LL.Settings.deleting() + : LL.Settings.permanentlyDeleteAccount()}