diff --git a/backend/.env.example b/backend/.env.example index d222712..885a625 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,3 +31,13 @@ MAX_FILE_SIZE=52428800 # =========================================== # Log level (debug, info, warn, error) LOG_LEVEL=debug + +# =========================================== +# Email Configuration (Resend) +# =========================================== +# Resend API key (optional - emails are skipped if not set) +RESEND_API_KEY= +# From email address for transactional emails +RESEND_FROM_EMAIL=noreply@example.com +# Application URL (used in email links) +APP_URL=http://localhost:3000 diff --git a/backend/package-lock.json b/backend/package-lock.json index 75554b3..1f944ae 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,6 +20,7 @@ "jsonwebtoken": "^9.0.3", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", + "resend": "^6.10.0", "uuid": "^9.0.1" }, "devDependencies": { @@ -1408,6 +1409,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -3596,6 +3603,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5919,6 +5932,12 @@ "node": ">=8" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6148,6 +6167,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.10.0.tgz", + "integrity": "sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "svix": "1.88.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6532,6 +6572,16 @@ "node": ">=8" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -6716,6 +6766,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.88.0.tgz", + "integrity": "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 9dd0005..0e3aed3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "jsonwebtoken": "^9.0.3", "morgan": "^1.10.0", "multer": "^1.4.5-lts.1", + "resend": "^6.10.0", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/backend/prisma/migrations/20260401120000_add_password_reset_and_role_requests/migration.sql b/backend/prisma/migrations/20260401120000_add_password_reset_and_role_requests/migration.sql new file mode 100644 index 0000000..396fa99 --- /dev/null +++ b/backend/prisma/migrations/20260401120000_add_password_reset_and_role_requests/migration.sql @@ -0,0 +1,46 @@ +-- CreateEnum +CREATE TYPE "RoleRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + +-- AlterTable: Change default role from DSL_DESIGNER to VIEWER +ALTER TABLE "users" ALTER COLUMN "role" SET DEFAULT 'VIEWER'; + +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "role_requests" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "currentRole" "UserRole" NOT NULL, + "requestedRole" "UserRole" NOT NULL, + "reason" TEXT NOT NULL, + "status" "RoleRequestStatus" NOT NULL DEFAULT 'PENDING', + "reviewedById" TEXT, + "reviewNote" TEXT, + "reviewedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "role_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_token_hash_key" ON "password_reset_tokens"("token_hash"); + +-- AddForeignKey +ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_requests" ADD CONSTRAINT "role_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_requests" ADD CONSTRAINT "role_requests_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c0c2635..21706bb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -30,6 +30,12 @@ enum ResourceType { TEST_CASE } +enum RoleRequestStatus { + PENDING + APPROVED + REJECTED +} + enum SharePermission { VIEWER EDITOR @@ -43,7 +49,7 @@ model User { id String @id @default(uuid()) email String @unique password String // Hashed with bcrypt - role UserRole @default(DSL_DESIGNER) + role UserRole @default(VIEWER) isSuspended Boolean @default(false) lastLogin DateTime? @@ -63,7 +69,12 @@ model User { // Sharing relations ownedShares SharedResource[] @relation("ShareOwner") receivedShares SharedResource[] @relation("ShareRecipient") - + + // Password reset & role request relations + passwordResetTokens PasswordResetToken[] + roleRequests RoleRequest[] + reviewedRoleRequests RoleRequest[] @relation("RoleRequestReviewer") + @@map("users") } @@ -373,3 +384,39 @@ enum FileType { model other } + +// =========================================== +// Password Reset Tokens +// =========================================== + +model PasswordResetToken { + id String @id @default(uuid()) + tokenHash String @unique @map("token_hash") + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + @@map("password_reset_tokens") +} + +// =========================================== +// Role Requests +// =========================================== + +model RoleRequest { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + currentRole UserRole + requestedRole UserRole + reason String + status RoleRequestStatus @default(PENDING) + reviewedById String? + reviewedBy User? @relation("RoleRequestReviewer", fields: [reviewedById], references: [id]) + reviewNote String? + reviewedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@map("role_requests") +} diff --git a/backend/src/__tests__/services/auth.service.test.ts b/backend/src/__tests__/services/auth.service.test.ts index f1d6bc6..eb9f262 100644 --- a/backend/src/__tests__/services/auth.service.test.ts +++ b/backend/src/__tests__/services/auth.service.test.ts @@ -19,6 +19,12 @@ jest.mock('../../services/metametamodel.service', () => ({ }, })); +// Mock email service +jest.mock('../../services/email.service', () => ({ + sendWelcomeEmail: jest.fn(), + sendPasswordResetEmail: jest.fn(), +})); + beforeEach(() => { mockReset(prismaMock); jest.clearAllMocks(); @@ -56,7 +62,7 @@ describe('AuthService', () => { expect.objectContaining({ data: expect.objectContaining({ email: 'test@example.com', - role: 'MODELER', + role: 'VIEWER', }), }) ); @@ -219,6 +225,107 @@ describe('AuthService', () => { }); }); + describe('requestPasswordReset', () => { + it('does nothing for unknown email (no token created)', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + + await authService.requestPasswordReset('unknown@example.com'); + + expect(prismaMock.passwordResetToken.create).not.toHaveBeenCalled(); + }); + + it('creates a hashed token and invalidates prior tokens', async () => { + prismaMock.user.findUnique.mockResolvedValue(mockUser); + prismaMock.passwordResetToken.updateMany.mockResolvedValue({ count: 0 }); + prismaMock.passwordResetToken.create.mockResolvedValue({ + id: 'token-1', + tokenHash: 'hashed-token', + userId: mockUser.id, + expiresAt: new Date(Date.now() + 3600000), + usedAt: null, + createdAt: new Date(), + }); + + await authService.requestPasswordReset('test@example.com'); + + // Should invalidate prior tokens + expect(prismaMock.passwordResetToken.updateMany).toHaveBeenCalledWith({ + where: { userId: mockUser.id, usedAt: null }, + data: expect.objectContaining({ usedAt: expect.any(Date) }), + }); + // Should create new token with hash (not raw) + expect(prismaMock.passwordResetToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tokenHash: expect.any(String), + userId: mockUser.id, + expiresAt: expect.any(Date), + }), + }); + }); + }); + + describe('resetPassword', () => { + const mockResetToken = { + id: 'token-1', + tokenHash: 'hashed-token', + userId: mockUser.id, + expiresAt: new Date(Date.now() + 3600000), + usedAt: null, + createdAt: new Date(), + user: mockUser, + }; + + it('resets password successfully with valid token', async () => { + prismaMock.passwordResetToken.findUnique.mockResolvedValue(mockResetToken); + prismaMock.$transaction.mockResolvedValue([{}, {}]); + + await expect( + authService.resetPassword('valid-raw-token', 'newpassword123') + ).resolves.toBeUndefined(); + + expect(prismaMock.passwordResetToken.findUnique).toHaveBeenCalledWith({ + where: { tokenHash: expect.any(String) }, + include: { user: true }, + }); + }); + + it('throws error for invalid token', async () => { + prismaMock.passwordResetToken.findUnique.mockResolvedValue(null); + + await expect( + authService.resetPassword('invalid-token', 'newpassword123') + ).rejects.toThrow('Invalid or expired reset token'); + }); + + it('throws error for already-used token', async () => { + prismaMock.passwordResetToken.findUnique.mockResolvedValue({ + ...mockResetToken, + usedAt: new Date(), + }); + + await expect( + authService.resetPassword('used-token', 'newpassword123') + ).rejects.toThrow('This reset token has already been used'); + }); + + it('throws error for expired token', async () => { + prismaMock.passwordResetToken.findUnique.mockResolvedValue({ + ...mockResetToken, + expiresAt: new Date(Date.now() - 1000), // expired + }); + + await expect( + authService.resetPassword('expired-token', 'newpassword123') + ).rejects.toThrow('Invalid or expired reset token'); + }); + + it('throws error when new password is too short', async () => { + await expect( + authService.resetPassword('some-token', 'short') + ).rejects.toThrow('Password must be at least 6 characters long'); + }); + }); + describe('verifyToken', () => { it('returns payload for valid token', () => { const token = jwt.sign( diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index e7df608..b89c32f 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -16,6 +16,9 @@ interface Config { secret: string; expiresIn: string; }; + resendApiKey: string; + resendFromEmail: string; + appUrl: string; } const config: Config = { @@ -29,6 +32,9 @@ const config: Config = { secret: process.env.JWT_SECRET || 'fallback-secret-key-change-in-production', expiresIn: process.env.JWT_EXPIRES_IN || '7d', }, + resendApiKey: process.env.RESEND_API_KEY || '', + resendFromEmail: process.env.RESEND_FROM_EMAIL || 'noreply@example.com', + appUrl: process.env.APP_URL || 'http://localhost:3000', }; // Validate required configuration diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index c30b1db..d19ceb4 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -113,6 +113,54 @@ router.post('/change-password', authenticate, async (req: AuthenticatedRequest, } }); +/** + * POST /api/auth/forgot-password + * Request a password reset email (public, rate-limited) + */ +router.post('/forgot-password', authLimiter, async (req: Request, res: Response) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + await authService.requestPasswordReset(email); + // Always return success to prevent email enumeration + res.json({ message: 'If an account with that email exists, a password reset link has been sent.' }); + } catch (error: any) { + console.error('Forgot password error:', error); + res.json({ message: 'If an account with that email exists, a password reset link has been sent.' }); + } +}); + +/** + * POST /api/auth/reset-password + * Reset password with token (public, rate-limited) + */ +router.post('/reset-password', authLimiter, async (req: Request, res: Response) => { + try { + const { token, newPassword } = req.body; + + if (!token || !newPassword) { + return res.status(400).json({ error: 'Token and new password are required' }); + } + + await authService.resetPassword(token, newPassword); + res.json({ message: 'Password has been reset successfully.' }); + } catch (error: any) { + console.error('Reset password error:', error); + + if (error.message.includes('Password must be') || + error.message.includes('Invalid or expired') || + error.message.includes('already been used')) { + return res.status(400).json({ error: error.message }); + } + + res.status(500).json({ error: 'Failed to reset password' }); + } +}); + /** * POST /api/auth/verify * Verify a token is still valid diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 2081229..67f4487 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -11,6 +11,7 @@ import testRoutes from './test.routes'; import fileRoutes from './file.routes'; import shareRoutes from './share.routes'; import adminRoutes from './admin.routes'; +import roleRequestRoutes from './roleRequest.routes'; const router = Router(); @@ -37,5 +38,6 @@ router.use('/tests', authenticate, testRoutes); router.use('/files', authenticate, fileRoutes); router.use('/share', authenticate, shareRoutes); router.use('/admin', authenticate, adminRoutes); +router.use('/role-requests', authenticate, roleRequestRoutes); export default router; diff --git a/backend/src/routes/roleRequest.routes.ts b/backend/src/routes/roleRequest.routes.ts new file mode 100644 index 0000000..e04d010 --- /dev/null +++ b/backend/src/routes/roleRequest.routes.ts @@ -0,0 +1,114 @@ +import { Router, Response } from 'express'; +import { AuthenticatedRequest } from '../middleware/auth'; +import { roleRequestService } from '../services/roleRequest.service'; + +const router = Router(); + +/** + * POST /api/role-requests + * Submit a role upgrade request (any authenticated user) + */ +router.post('/', async (req: AuthenticatedRequest, res: Response) => { + try { + const { requestedRole, reason } = req.body; + + if (!requestedRole || !reason) { + return res.status(400).json({ error: 'requestedRole and reason are required' }); + } + + const request = await roleRequestService.createRequest( + req.user!.userId, + requestedRole, + reason + ); + + res.status(201).json({ success: true, data: request }); + } catch (error: any) { + console.error('Create role request error:', error); + + if (error.message.includes('already have a pending') || + error.message.includes('must be higher') || + error.message.includes('Cannot request ADMIN')) { + return res.status(400).json({ error: error.message }); + } + + res.status(500).json({ error: 'Failed to create role request' }); + } +}); + +/** + * GET /api/role-requests/my + * Get current user's role requests + */ +router.get('/my', async (req: AuthenticatedRequest, res: Response) => { + try { + const requests = await roleRequestService.getMyRequests(req.user!.userId); + res.json({ success: true, data: requests }); + } catch (error: any) { + console.error('Get my role requests error:', error); + res.status(500).json({ error: 'Failed to get role requests' }); + } +}); + +/** + * GET /api/role-requests + * List all role requests (ADMIN only) + */ +router.get('/', async (req: AuthenticatedRequest, res: Response) => { + try { + if (req.user!.role !== 'ADMIN') { + return res.status(403).json({ error: 'Admin access required' }); + } + + const status = req.query.status as string | undefined; + const validStatuses = ['PENDING', 'APPROVED', 'REJECTED']; + if (status && !validStatuses.includes(status)) { + return res.status(400).json({ error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` }); + } + const filters = status ? { status: status as 'PENDING' | 'APPROVED' | 'REJECTED' } : undefined; + + const requests = await roleRequestService.getAllRequests(filters); + res.json({ success: true, data: requests }); + } catch (error: any) { + console.error('Get all role requests error:', error); + res.status(500).json({ error: 'Failed to get role requests' }); + } +}); + +/** + * PATCH /api/role-requests/:requestId + * Approve or reject a role request (ADMIN only) + */ +router.patch('/:requestId', async (req: AuthenticatedRequest, res: Response) => { + try { + if (req.user!.role !== 'ADMIN') { + return res.status(403).json({ error: 'Admin access required' }); + } + + const { approved, reviewNote } = req.body; + + if (typeof approved !== 'boolean') { + return res.status(400).json({ error: 'approved (boolean) is required' }); + } + + const result = await roleRequestService.reviewRequest( + req.params.requestId, + req.user!.userId, + approved, + reviewNote + ); + + res.json({ success: true, data: result }); + } catch (error: any) { + console.error('Review role request error:', error); + + if (error.message.includes('not found') || + error.message.includes('already been reviewed')) { + return res.status(400).json({ error: error.message }); + } + + res.status(500).json({ error: 'Failed to review role request' }); + } +}); + +export default router; diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index d9dc53f..d16ab0c 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -1,10 +1,12 @@ import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; import config from '../config'; import { JwtPayload } from '../middleware/auth'; import { UserRole } from '../../../shared/types'; import { metametamodelService } from './metametamodel.service'; import prisma from '../config/database'; +import { sendWelcomeEmail, sendPasswordResetEmail } from './email.service'; const SALT_ROUNDS = 10; @@ -92,12 +94,12 @@ class AuthService { // Hash password const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); - // Create user with default MODELER role + // Create user with default VIEWER role const user = await prisma.user.create({ data: { email: email.toLowerCase(), password: hashedPassword, - role: 'MODELER', + role: 'VIEWER', }, }); @@ -109,6 +111,9 @@ class AuthService { // Don't fail registration if ePackage init fails - user can still use the app } + // Send welcome email (fire-and-forget) + sendWelcomeEmail(user.email); + // Generate token const token = this.generateToken(user); @@ -218,6 +223,88 @@ class AuthService { }); } + /** + * Request password reset - generates token and sends email + * Always succeeds (no information leakage about whether email exists) + */ + /** + * Hash a token using SHA-256 + */ + private hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + async requestPasswordReset(email: string): Promise { + const user = await prisma.user.findUnique({ + where: { email: email.toLowerCase() }, + }); + + if (!user) { + return; // Silent - don't reveal if email exists + } + + // Invalidate any prior unused tokens for this user + await prisma.passwordResetToken.updateMany({ + where: { userId: user.id, usedAt: null }, + data: { usedAt: new Date() }, + }); + + const rawToken = crypto.randomBytes(32).toString('hex'); + const tokenHash = this.hashToken(rawToken); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await prisma.passwordResetToken.create({ + data: { + tokenHash, + userId: user.id, + expiresAt, + }, + }); + + sendPasswordResetEmail(user.email, rawToken); + } + + /** + * Reset password using token + */ + async resetPassword(token: string, newPassword: string): Promise { + const passwordValidation = this.validatePassword(newPassword); + if (!passwordValidation.valid) { + throw new Error(passwordValidation.message); + } + + const tokenHash = this.hashToken(token); + const resetToken = await prisma.passwordResetToken.findUnique({ + where: { tokenHash }, + include: { user: true }, + }); + + if (!resetToken) { + throw new Error('Invalid or expired reset token'); + } + + if (resetToken.usedAt) { + throw new Error('This reset token has already been used'); + } + + if (resetToken.expiresAt < new Date()) { + throw new Error('Invalid or expired reset token'); + } + + const hashedPassword = await bcrypt.hash(newPassword, SALT_ROUNDS); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: resetToken.userId }, + data: { password: hashedPassword }, + }), + prisma.passwordResetToken.update({ + where: { id: resetToken.id }, + data: { usedAt: new Date() }, + }), + ]); + } + /** * Verify token and return payload */ diff --git a/backend/src/services/email.service.ts b/backend/src/services/email.service.ts new file mode 100644 index 0000000..de86de0 --- /dev/null +++ b/backend/src/services/email.service.ts @@ -0,0 +1,129 @@ +import { Resend } from 'resend'; +import config from '../config'; + +let resend: Resend | null = null; + +if (config.resendApiKey) { + resend = new Resend(config.resendApiKey); +} + +const from = config.resendFromEmail; +const appUrl = config.appUrl; + +/** + * Escape HTML special characters to prevent injection in email bodies + */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Mask email address for safe logging (PII protection) + */ +function maskEmail(email: string): string { + if (!email) return ''; + const atIndex = email.indexOf('@'); + if (atIndex <= 0) { + return email.length <= 1 ? '*' : email[0] + '***'; + } + const localPart = email.slice(0, atIndex); + const domainPart = email.slice(atIndex); + if (localPart.length <= 2) { + return localPart[0] + '***' + domainPart; + } + return localPart.slice(0, 2) + '***' + domainPart; +} + +async function sendEmail(to: string, subject: string, html: string): Promise { + const maskedTo = maskEmail(to); + if (!resend) { + console.log(`[Email] Skipped (no API key): "${subject}" to ${maskedTo}`); + return; + } + try { + await resend.emails.send({ from, to, subject, html }); + console.log(`[Email] Sent: "${subject}" to ${maskedTo}`); + } catch (error) { + console.error(`[Email] Failed: "${subject}" to ${maskedTo}`, error); + } +} + +export async function sendWelcomeEmail(email: string): Promise { + await sendEmail( + email, + 'Welcome to SpatialDSL Studio', + `

Welcome to SpatialDSL Studio!

+

Your account has been created successfully.

+

You can log in at: ${appUrl}

+

You have been assigned the VIEWER role. To request additional permissions, use the "Request Role Upgrade" option in the application.

` + ); +} + +export async function sendPasswordResetEmail(email: string, token: string): Promise { + const resetUrl = `${appUrl}/reset-password?token=${encodeURIComponent(token)}`; + await sendEmail( + email, + 'Reset Your Password - SpatialDSL Studio', + `

Password Reset Request

+

You requested a password reset. Click the link below to set a new password:

+

${escapeHtml(resetUrl)}

+

This link expires in 1 hour. If you did not request this, you can ignore this email.

` + ); +} + +export async function sendShareNotificationEmail( + recipientEmail: string, + ownerEmail: string, + resourceType: string, + resourceName: string +): Promise { + await sendEmail( + recipientEmail, + `A ${resourceType.toLowerCase()} has been shared with you - SpatialDSL Studio`, + `

Resource Shared With You

+

${escapeHtml(ownerEmail)} shared a ${escapeHtml(resourceType.toLowerCase())} with you: ${escapeHtml(resourceName)}

+

View it in the app: ${appUrl}

` + ); +} + +export async function sendRoleRequestSubmittedEmail( + adminEmails: string[], + requesterEmail: string, + requestedRole: string, + reason: string +): Promise { + for (const adminEmail of adminEmails) { + await sendEmail( + adminEmail, + 'New Role Request - SpatialDSL Studio', + `

New Role Upgrade Request

+

${escapeHtml(requesterEmail)} has requested a role upgrade to ${escapeHtml(requestedRole)}.

+

Reason: ${escapeHtml(reason)}

+

Review this request in the Admin Panel.

` + ); + } +} + +export async function sendRoleRequestReviewedEmail( + userEmail: string, + requestedRole: string, + approved: boolean, + reviewNote?: string +): Promise { + const status = approved ? 'approved' : 'rejected'; + const noteHtml = reviewNote ? `

Note from admin: ${escapeHtml(reviewNote)}

` : ''; + await sendEmail( + userEmail, + `Role Request ${approved ? 'Approved' : 'Rejected'} - SpatialDSL Studio`, + `

Role Request ${approved ? 'Approved' : 'Rejected'}

+

Your request for the ${escapeHtml(requestedRole)} role has been ${status}.

+ ${noteHtml} +

${approved ? 'Your role has been updated. Log in to access your new permissions.' : 'You can submit a new request with additional information if needed.'}

+

${appUrl}

` + ); +} diff --git a/backend/src/services/roleRequest.service.ts b/backend/src/services/roleRequest.service.ts new file mode 100644 index 0000000..65f4c1c --- /dev/null +++ b/backend/src/services/roleRequest.service.ts @@ -0,0 +1,128 @@ +import prisma from '../config/database'; +import { UserRole } from '../../../shared/types'; +import { RoleRequestStatus } from '@prisma/client'; +import { sendRoleRequestSubmittedEmail, sendRoleRequestReviewedEmail } from './email.service'; + +const ROLE_HIERARCHY: UserRole[] = ['VIEWER', 'MODELER', 'DSL_DESIGNER', 'ADMIN']; + +function roleIndex(role: UserRole): number { + return ROLE_HIERARCHY.indexOf(role); +} + +class RoleRequestService { + async createRequest(userId: string, requestedRole: UserRole, reason: string) { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new Error('User not found'); + + const currentRole = user.role as UserRole; + + if (roleIndex(requestedRole) <= roleIndex(currentRole)) { + throw new Error('Requested role must be higher than your current role'); + } + + if (requestedRole === 'ADMIN') { + throw new Error('Cannot request ADMIN role'); + } + + // Check for existing pending request + const existing = await prisma.roleRequest.findFirst({ + where: { userId, status: 'PENDING' }, + }); + if (existing) { + throw new Error('You already have a pending role request'); + } + + const request = await prisma.roleRequest.create({ + data: { + userId, + currentRole: user.role, + requestedRole, + reason, + }, + include: { user: { select: { email: true } } }, + }); + + // Notify admins + const admins = await prisma.user.findMany({ + where: { role: 'ADMIN' }, + select: { email: true }, + }); + sendRoleRequestSubmittedEmail( + admins.map(a => a.email), + request.user.email, + requestedRole, + reason + ); + + return request; + } + + async getMyRequests(userId: string) { + return prisma.roleRequest.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async getAllRequests(filters?: { status?: RoleRequestStatus }) { + return prisma.roleRequest.findMany({ + where: filters?.status ? { status: filters.status } : undefined, + include: { + user: { select: { email: true } }, + reviewedBy: { select: { email: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async reviewRequest( + requestId: string, + reviewerId: string, + approved: boolean, + reviewNote?: string + ) { + const request = await prisma.roleRequest.findUnique({ + where: { id: requestId }, + include: { user: { select: { email: true } } }, + }); + + if (!request) throw new Error('Role request not found'); + if (request.status !== 'PENDING') throw new Error('This request has already been reviewed'); + + const status: RoleRequestStatus = approved ? 'APPROVED' : 'REJECTED'; + + const updated = await prisma.$transaction(async (tx) => { + const updatedRequest = await tx.roleRequest.update({ + where: { id: requestId }, + data: { + status, + reviewedById: reviewerId, + reviewNote: reviewNote || null, + reviewedAt: new Date(), + }, + include: { user: { select: { email: true } } }, + }); + + if (approved) { + await tx.user.update({ + where: { id: request.userId }, + data: { role: request.requestedRole }, + }); + } + + return updatedRequest; + }); + + // Send notification to user + sendRoleRequestReviewedEmail( + updated.user.email, + updated.requestedRole, + approved, + reviewNote + ); + + return updated; + } +} + +export const roleRequestService = new RoleRequestService(); diff --git a/backend/src/services/sharing.service.ts b/backend/src/services/sharing.service.ts index d3f1eb9..8da2729 100644 --- a/backend/src/services/sharing.service.ts +++ b/backend/src/services/sharing.service.ts @@ -1,12 +1,13 @@ import prisma from '../config/database'; import { ApiError } from '../middleware'; -import { - ResourceType, - SharePermission, +import { + ResourceType, + SharePermission, SharedResource as SharedResourceType, - UserRole + UserRole } from '../../../shared/types'; import { ResourceType as PrismaResourceType, SharePermission as PrismaSharePermission } from '@prisma/client'; +import { sendShareNotificationEmail } from './email.service'; // Map string types to Prisma enums const toPrismaResourceType = (type: ResourceType): PrismaResourceType => type as PrismaResourceType; @@ -111,9 +112,52 @@ class SharingService { }, }); + // Send share notification email (fire-and-forget) + const resourceName = await this.getResourceDisplayName(resourceType, resourceId); + sendShareNotificationEmail( + targetUser.email, + owner.email, + resourceType, + resourceName + ); + return this.mapToSharedResource(share); } + /** + * Get a human-readable display name for a resource + */ + private async getResourceDisplayName(resourceType: ResourceType, resourceId: string): Promise { + switch (resourceType) { + case 'MODEL': { + const model = await prisma.model.findUnique({ where: { id: resourceId }, select: { name: true } }); + return model?.name ?? resourceId; + } + case 'METAMODEL': { + const metamodel = await prisma.metamodel.findUnique({ where: { id: resourceId }, select: { name: true } }); + return metamodel?.name ?? resourceId; + } + case 'DIAGRAM': { + const diagram = await prisma.diagram.findUnique({ where: { id: resourceId }, select: { name: true } }); + return diagram?.name ?? resourceId; + } + case 'CODEGEN_PROJECT': { + const project = await prisma.codeGenerationProject.findUnique({ where: { id: resourceId }, select: { name: true } }); + return project?.name ?? resourceId; + } + case 'TRANSFORMATION_RULE': { + const rule = await prisma.transformationRule.findUnique({ where: { id: resourceId }, select: { name: true } }); + return rule?.name ?? resourceId; + } + case 'TEST_CASE': { + const testCase = await prisma.testCase.findUnique({ where: { id: resourceId }, select: { name: true } }); + return testCase?.name ?? resourceId; + } + default: + return resourceId; + } + } + /** * Share a resource with cascading to dependencies * When sharing a model, also shares its metamodel diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b3fc75d..969d6b5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -35,6 +35,9 @@ services: JWT_EXPIRES_IN: 7d ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} + RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@example.com} + APP_URL: ${APP_URL:-https://spatialdsl.tmusml.cloud} # No ports exposed to host — frontend nginx proxies /api/* internally frontend: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96cf124..0f38428 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,7 @@ import ShareIcon from '@mui/icons-material/Share'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import HomeIcon from '@mui/icons-material/Home'; import InfoIcon from '@mui/icons-material/Info'; +import UpgradeIcon from '@mui/icons-material/Upgrade'; import MetamodelManager from './components/metamodel/MetamodelManager'; import ModelManager from './components/model/ModelManager'; @@ -61,6 +62,9 @@ import TransformationDashboard from './components/transformation/TransformationD import ModelBasedTestingDashboard from './components/testing/ModelBasedTestingDashboard'; import TestDetails from './components/testing/TestDetails'; import LoginPage from './components/auth/LoginPage'; +import ForgotPasswordPage from './components/auth/ForgotPasswordPage'; +import ResetPasswordPage from './components/auth/ResetPasswordPage'; +import RoleRequestDialog from './components/auth/RoleRequestDialog'; import { AdminPanel } from './components/admin'; import { ShareDialog } from './components/common'; import { AuthProvider, useAuth } from './contexts/AuthContext'; @@ -201,6 +205,7 @@ const App: React.FC = () => { const AuthenticatedApp: React.FC = () => { const { isAuthenticated, isLoading, user, logout, isAdmin } = useAuth(); const [drawerOpen, setDrawerOpen] = useState(false); + const [roleRequestDialogOpen, setRoleRequestDialogOpen] = useState(false); const toggleDrawer = () => { setDrawerOpen(!drawerOpen); @@ -568,9 +573,17 @@ const AuthenticatedApp: React.FC = () => { ); } - // Show login page if not authenticated + // Show login/forgot/reset pages if not authenticated if (!isAuthenticated) { - return ; + return ( + + + } /> + } /> + } /> + + + ); } return ( @@ -718,6 +731,17 @@ const AuthenticatedApp: React.FC = () => { )} + {!isAdmin && ( + <> + + + setRoleRequestDialogOpen(true)}> + + + + + + )} @@ -749,6 +773,11 @@ const AuthenticatedApp: React.FC = () => { + + setRoleRequestDialogOpen(false)} + /> ); }; diff --git a/frontend/src/__mocks__/react-router-dom.js b/frontend/src/__mocks__/react-router-dom.js new file mode 100644 index 0000000..bc92019 --- /dev/null +++ b/frontend/src/__mocks__/react-router-dom.js @@ -0,0 +1,11 @@ +module.exports = { + useNavigate: () => jest.fn(), + useParams: () => ({}), + useSearchParams: () => [new URLSearchParams(), jest.fn()], + useLocation: () => ({ pathname: '/', search: '', hash: '', state: null }), + Link: ({ children, to, ...props }) => {children}, + BrowserRouter: ({ children }) => <>{children}, + Routes: ({ children }) => <>{children}, + Route: () => null, + MemoryRouter: ({ children }) => <>{children}, +}; diff --git a/frontend/src/components/admin/AdminPanel.tsx b/frontend/src/components/admin/AdminPanel.tsx index d4872d0..8f6dacb 100644 --- a/frontend/src/components/admin/AdminPanel.tsx +++ b/frontend/src/components/admin/AdminPanel.tsx @@ -24,11 +24,13 @@ import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import HomeIcon from '@mui/icons-material/Home'; +import AssignmentIcon from '@mui/icons-material/Assignment'; import { useAuth } from '../../contexts/AuthContext'; import UserManagement from './UserManagement'; import ResourceDashboard from './ResourceDashboard'; import ResourceManagement from './ResourceManagement'; import SystemMonitoring from './SystemMonitoring'; +import RoleRequestManagement from './RoleRequestManagement'; interface TabPanelProps { children?: React.ReactNode; @@ -161,6 +163,12 @@ const AdminPanel: React.FC = () => { label="System Monitoring" {...a11yProps(3)} /> + } + iconPosition="start" + label="Role Requests" + {...a11yProps(4)} + /> @@ -177,6 +185,9 @@ const AdminPanel: React.FC = () => { + + + ); }; diff --git a/frontend/src/components/admin/RoleRequestManagement.tsx b/frontend/src/components/admin/RoleRequestManagement.tsx new file mode 100644 index 0000000..d96255c --- /dev/null +++ b/frontend/src/components/admin/RoleRequestManagement.tsx @@ -0,0 +1,234 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, + Paper, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Button, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + CircularProgress, + Alert, +} from '@mui/material'; +import { Check as CheckIcon, Close as CloseIcon } from '@mui/icons-material'; +import { roleRequestService, RoleRequest } from '../../services/core'; + +const RoleRequestManagement: React.FC = () => { + const [requests, setRequests] = useState([]); + const [statusFilter, setStatusFilter] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Review dialog state + const [reviewDialog, setReviewDialog] = useState<{ open: boolean; request: RoleRequest | null; approve: boolean }>({ + open: false, + request: null, + approve: true, + }); + const [reviewNote, setReviewNote] = useState(''); + const [isReviewing, setIsReviewing] = useState(false); + + const loadRequests = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await roleRequestService.getAllRequests(statusFilter || undefined); + setRequests(data); + } catch (err: any) { + setError(err.message); + } finally { + setIsLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + loadRequests(); + }, [loadRequests]); + + const openReviewDialog = (request: RoleRequest, approve: boolean) => { + setReviewDialog({ open: true, request, approve }); + setReviewNote(''); + }; + + const handleReview = async () => { + if (!reviewDialog.request) return; + setIsReviewing(true); + try { + await roleRequestService.reviewRequest( + reviewDialog.request.id, + reviewDialog.approve, + reviewNote || undefined + ); + setReviewDialog({ open: false, request: null, approve: true }); + loadRequests(); + } catch (err: any) { + setError(err.message); + } finally { + setIsReviewing(false); + } + }; + + const statusColor = (status: string) => { + switch (status) { + case 'PENDING': return 'warning'; + case 'APPROVED': return 'success'; + case 'REJECTED': return 'error'; + default: return 'default'; + } + }; + + return ( + + + Role Requests + + Filter by Status + + + + + {error && ( + setError(null)}> + {error} + + )} + + {isLoading ? ( + + + + ) : requests.length === 0 ? ( + + No role requests found. + + ) : ( + + + + + User + Current Role + Requested Role + Reason + Status + Date + Actions + + + + {requests.map((req) => ( + + {req.user?.email || req.userId} + {req.currentRole} + {req.requestedRole} + + {req.reason} + + + + + {new Date(req.createdAt).toLocaleDateString()} + + {req.status === 'PENDING' ? ( + + + + + ) : ( + + {req.reviewedBy?.email && `by ${req.reviewedBy.email}`} + + )} + + + ))} + +
+
+ )} + + {/* Review Dialog */} + setReviewDialog({ open: false, request: null, approve: true })} maxWidth="sm" fullWidth> + + {reviewDialog.approve ? 'Approve' : 'Reject'} Role Request + + + {reviewDialog.request && ( + <> + + {reviewDialog.request.user?.email} is requesting{' '} + {reviewDialog.request.requestedRole} role (currently {reviewDialog.request.currentRole}). + + + Reason: {reviewDialog.request.reason} + + setReviewNote(e.target.value)} + disabled={isReviewing} + /> + + )} + + + + + + +
+ ); +}; + +export default RoleRequestManagement; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index 738d00d..65a201b 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -7,3 +7,4 @@ export { default as UserManagement } from './UserManagement'; export { default as ResourceDashboard } from './ResourceDashboard'; export { default as ResourceManagement } from './ResourceManagement'; export { default as SystemMonitoring } from './SystemMonitoring'; +export { default as RoleRequestManagement } from './RoleRequestManagement'; diff --git a/frontend/src/components/auth/ForgotPasswordPage.tsx b/frontend/src/components/auth/ForgotPasswordPage.tsx new file mode 100644 index 0000000..21fc6c1 --- /dev/null +++ b/frontend/src/components/auth/ForgotPasswordPage.tsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import { + Box, + Card, + CardContent, + TextField, + Button, + Typography, + Alert, + CircularProgress, + Link, +} from '@mui/material'; +import { ArrowBack as ArrowBackIcon } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { authApi } from '../../services/core'; + +const ForgotPasswordPage: React.FC = () => { + const navigate = useNavigate(); + const [email, setEmail] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!email.trim()) { + setError('Email is required'); + return; + } + + setIsLoading(true); + try { + await authApi.forgotPassword(email.trim()); + setSubmitted(true); + } catch (err: any) { + setError(err.message); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + Forgot Password + + + + {submitted ? ( + <> + + If an account with that email exists, a password reset link has been sent. Check your inbox. + + + + ) : ( + <> + + Enter your email address and we'll send you a link to reset your password. + + + {error && ( + setError(null)}> + {error} + + )} + +
+ setEmail(e.target.value)} + margin="normal" + autoComplete="email" + autoFocus + disabled={isLoading} + /> + + + + + + navigate('/')} + sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }} + > + + Back to Login + + + + )} +
+
+
+ ); +}; + +export default ForgotPasswordPage; diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx index a82d86d..ee3e42b 100644 --- a/frontend/src/components/auth/LoginPage.tsx +++ b/frontend/src/components/auth/LoginPage.tsx @@ -20,11 +20,13 @@ import { PersonAdd as RegisterIcon, CheckCircle as CheckCircleIcon, } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; type AuthMode = 'login' | 'register'; const LoginPage: React.FC = () => { + const navigate = useNavigate(); const { login, register, error, clearError, isLoading, registrationSuccess, clearRegistrationSuccess } = useAuth(); const [mode, setMode] = useState('login'); const [email, setEmail] = useState(''); @@ -229,6 +231,20 @@ const LoginPage: React.FC = () => { + {mode === 'login' && ( + + navigate('/forgot-password')} + sx={{ fontWeight: 500, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }} + > + Forgot password? + + + )} + or diff --git a/frontend/src/components/auth/ResetPasswordPage.tsx b/frontend/src/components/auth/ResetPasswordPage.tsx new file mode 100644 index 0000000..9ec9571 --- /dev/null +++ b/frontend/src/components/auth/ResetPasswordPage.tsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; +import { + Box, + Card, + CardContent, + TextField, + Button, + Typography, + Alert, + CircularProgress, + InputAdornment, + IconButton, +} from '@mui/material'; +import { Visibility, VisibilityOff } from '@mui/icons-material'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { authApi } from '../../services/core'; + +const ResetPasswordPage: React.FC = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = searchParams.get('token') || ''; + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!token) { + setError('Invalid reset link. Please request a new password reset.'); + return; + } + + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setIsLoading(true); + try { + await authApi.resetPassword(token, password); + setSuccess(true); + } catch (err: any) { + setError(err.message); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + Reset Password + + + + {success ? ( + <> + + Your password has been reset successfully! + + + + ) : ( + <> + {error && ( + setError(null)}> + {error} + + )} + + {!token && ( + + Invalid reset link. Please request a new password reset. + + )} + +
+ setPassword(e.target.value)} + margin="normal" + autoComplete="new-password" + autoFocus + disabled={isLoading || !token} + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} edge="end" disabled={isLoading}> + {showPassword ? : } + + + ), + }} + /> + + setConfirmPassword(e.target.value)} + margin="normal" + autoComplete="new-password" + disabled={isLoading || !token} + /> + + + + + )} +
+
+
+ ); +}; + +export default ResetPasswordPage; diff --git a/frontend/src/components/auth/RoleRequestDialog.tsx b/frontend/src/components/auth/RoleRequestDialog.tsx new file mode 100644 index 0000000..521c626 --- /dev/null +++ b/frontend/src/components/auth/RoleRequestDialog.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Typography, + Alert, + CircularProgress, + Box, + Chip, + Divider, +} from '@mui/material'; +import { useAuth } from '../../contexts/AuthContext'; +import { roleRequestService, RoleRequest, UserRole } from '../../services/core'; + +const ROLE_HIERARCHY: UserRole[] = ['VIEWER', 'MODELER', 'DSL_DESIGNER', 'ADMIN']; + +interface RoleRequestDialogProps { + open: boolean; + onClose: () => void; +} + +const RoleRequestDialog: React.FC = ({ open, onClose }) => { + const { user } = useAuth(); + const [requestedRole, setRequestedRole] = useState('MODELER'); + const [reason, setReason] = useState(''); + const [myRequests, setMyRequests] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const currentRoleIndex = user ? ROLE_HIERARCHY.indexOf(user.role) : 0; + const availableRoles = ROLE_HIERARCHY.filter((_, i) => i > currentRoleIndex && i < ROLE_HIERARCHY.length - 1); // Exclude ADMIN + + const loadMyRequests = useCallback(async () => { + setIsLoading(true); + try { + const requests = await roleRequestService.getMyRequests(); + setMyRequests(requests); + } catch (err: any) { + console.error('Failed to load requests:', err); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (open) { + setError(null); + setSuccess(false); + setReason(''); + loadMyRequests(); + // Set default requested role based on available options + const roles = ROLE_HIERARCHY.filter((_, i) => i > currentRoleIndex && i < ROLE_HIERARCHY.length - 1); + if (roles.length > 0) { + setRequestedRole(roles[0]); + } + } + }, [open, loadMyRequests, currentRoleIndex]); + + const handleSubmit = async () => { + setError(null); + if (!reason.trim()) { + setError('Please provide a reason for your request'); + return; + } + + setIsSubmitting(true); + try { + await roleRequestService.createRequest(requestedRole, reason.trim()); + setSuccess(true); + setReason(''); + loadMyRequests(); + } catch (err: any) { + setError(err.message); + } finally { + setIsSubmitting(false); + } + }; + + const hasPendingRequest = myRequests.some(r => r.status === 'PENDING'); + + const statusColor = (status: string) => { + switch (status) { + case 'PENDING': return 'warning'; + case 'APPROVED': return 'success'; + case 'REJECTED': return 'error'; + default: return 'default'; + } + }; + + return ( + + Request Role Upgrade + + + Current role: {user?.role} + + + {success && ( + setSuccess(false)}> + Role request submitted successfully! An admin will review it. + + )} + + {error && ( + setError(null)}> + {error} + + )} + + {availableRoles.length === 0 ? ( + You already have the highest requestable role. + ) : hasPendingRequest ? ( + + You have a pending role request. Please wait for an admin to review it. + + ) : !success && ( + + + Requested Role + + + + setReason(e.target.value)} + disabled={isSubmitting} + placeholder="Explain why you need this role..." + /> + + )} + + {myRequests.length > 0 && ( + <> + + Request History + {isLoading ? ( + + ) : ( + myRequests.map(req => ( + + + + {req.currentRole} → {req.requestedRole} + + + + + {new Date(req.createdAt).toLocaleDateString()} + + {req.reviewNote && ( + + Admin note: {req.reviewNote} + + )} + + )) + )} + + )} + + + + {availableRoles.length > 0 && !hasPendingRequest && !success && ( + + )} + + + ); +}; + +export default RoleRequestDialog; diff --git a/frontend/src/services/core/api.client.ts b/frontend/src/services/core/api.client.ts index ca4c2cc..01d56c0 100644 --- a/frontend/src/services/core/api.client.ts +++ b/frontend/src/services/core/api.client.ts @@ -252,6 +252,12 @@ export const API_ENDPOINTS = { AUTH_ME: '/auth/me', AUTH_VERIFY: '/auth/verify', AUTH_CHANGE_PASSWORD: '/auth/change-password', + AUTH_FORGOT_PASSWORD: '/auth/forgot-password', + AUTH_RESET_PASSWORD: '/auth/reset-password', + + // Role Requests + ROLE_REQUESTS: '/role-requests', + ROLE_REQUESTS_MY: '/role-requests/my', // Health HEALTH: '/health', @@ -379,6 +385,32 @@ export const authApi = { const result = await response.json(); return result; }, + + forgotPassword: async (email: string): Promise<{ message: string }> => { + const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.AUTH_FORGOT_PASSWORD}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || 'Failed to send reset email'); + } + return result; + }, + + resetPassword: async (token: string, newPassword: string): Promise<{ message: string }> => { + const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.AUTH_RESET_PASSWORD}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, newPassword }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || 'Failed to reset password'); + } + return result; + }, }; export default apiClient; diff --git a/frontend/src/services/core/index.ts b/frontend/src/services/core/index.ts index 2ff50af..5ddf646 100644 --- a/frontend/src/services/core/index.ts +++ b/frontend/src/services/core/index.ts @@ -3,6 +3,8 @@ export { apiClient, ApiClient, API_ENDPOINTS, authApi } from './api.client'; export type { LoginRequest, RegisterRequest, AuthUser, AuthResponse, UserRole, ResourceType } from './api.client'; export { fileStorageService } from './fileStorage.service'; export { adminService } from './admin.service'; +export { roleRequestService } from './roleRequest.service'; +export type { RoleRequest } from './roleRequest.service'; export type { AdminUser, UserListParams, diff --git a/frontend/src/services/core/roleRequest.service.ts b/frontend/src/services/core/roleRequest.service.ts new file mode 100644 index 0000000..6880cf7 --- /dev/null +++ b/frontend/src/services/core/roleRequest.service.ts @@ -0,0 +1,41 @@ +import { apiClient, API_ENDPOINTS, UserRole } from './api.client'; + +export interface RoleRequest { + id: string; + userId: string; + currentRole: UserRole; + requestedRole: UserRole; + reason: string; + status: 'PENDING' | 'APPROVED' | 'REJECTED'; + reviewedById?: string; + reviewNote?: string; + reviewedAt?: string; + createdAt: string; + updatedAt: string; + user?: { email: string }; + reviewedBy?: { email: string }; +} + +class RoleRequestService { + async createRequest(requestedRole: UserRole, reason: string): Promise { + return apiClient.post(API_ENDPOINTS.ROLE_REQUESTS, { requestedRole, reason }); + } + + async getMyRequests(): Promise { + return apiClient.get(API_ENDPOINTS.ROLE_REQUESTS_MY); + } + + async getAllRequests(status?: string): Promise { + const query = status ? `?status=${status}` : ''; + return apiClient.get(`${API_ENDPOINTS.ROLE_REQUESTS}${query}`); + } + + async reviewRequest(requestId: string, approved: boolean, reviewNote?: string): Promise { + return apiClient.patch( + `${API_ENDPOINTS.ROLE_REQUESTS}/${requestId}`, + { approved, reviewNote } + ); + } +} + +export const roleRequestService = new RoleRequestService();