From f06f23cd211b9c3459efec849e03fc4cbb481a25 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Sun, 14 Jun 2026 23:16:55 +0530 Subject: [PATCH 1/7] feat: team collaboration, auth response fixes, and member limit enforcement --- .../src/__tests__/auth.controller.test.js | 4 +- .../src/__tests__/loadProjectForAdmin.test.js | 3 +- .../project.controller.softDelete.test.js | 3 +- .../__tests__/routes.projects.storage.test.js | 15 +- .../storage.presigned.controller.test.js | 1 + .../src/__tests__/webhook.controller.test.js | 1 + apps/dashboard-api/src/app.js | 2 + .../src/controllers/ai.controller.js | 4 +- .../src/controllers/analytics.controller.js | 15 +- .../src/controllers/auth.controller.js | 4 +- .../src/controllers/dbExport.controller.js | 6 +- .../src/controllers/invitation.controller.js | 147 ++++++++ .../src/controllers/project.controller.js | 295 +++++++++++++--- .../src/controllers/webhook.controller.js | 29 +- .../src/middlewares/authorizeProject.js | 51 +++ .../src/middlewares/loadProjectForAdmin.js | 7 +- .../src/middlewares/planEnforcement.js | 57 +++- apps/dashboard-api/src/routes/invitations.js | 27 ++ apps/dashboard-api/src/routes/projects.js | 112 ++++--- apps/dashboard-api/src/routes/webhooks.js | 16 +- apps/web-dashboard/src/App.jsx | 3 + .../src/components/Auth/UserTable.jsx | 12 +- .../src/components/CollectionTable.jsx | 6 +- .../components/Database/DatabaseHeader.jsx | 6 +- .../src/components/DatabaseSidebar.jsx | 65 ++-- .../src/components/Layout/ProjectNavbar.jsx | 7 +- .../src/components/Layout/Sidebar.jsx | 5 +- .../src/components/RecordList.jsx | 4 +- apps/web-dashboard/src/pages/Auth.jsx | 9 +- .../src/pages/CreateCollection.jsx | 14 +- apps/web-dashboard/src/pages/Dashboard.jsx | 100 +++++- apps/web-dashboard/src/pages/Database.jsx | 25 +- apps/web-dashboard/src/pages/MailPlatform.jsx | 268 ++++++++------- .../src/pages/ProjectSettings.jsx | 316 ++++++++++-------- apps/web-dashboard/src/pages/Releases.jsx | 2 +- apps/web-dashboard/src/pages/Storage.jsx | 67 ++-- apps/web-dashboard/src/pages/TeamMembers.jsx | 298 +++++++++++++++++ apps/web-dashboard/src/pages/Webhooks.jsx | 51 ++- apps/web-dashboard/src/utils/api.js | 14 +- packages/common/src/index.js | 14 +- packages/common/src/models/Invitation.js | 55 +++ packages/common/src/models/Project.js | 16 + packages/common/src/utils/emailService.js | 2 +- packages/common/src/utils/planLimits.js | 6 +- packages/common/src/utils/projectAccess.js | 33 ++ sdks/urbackend-react/dist/index.d.mts | 2 + sdks/urbackend-react/dist/index.d.ts | 2 + sdks/urbackend-react/dist/index.js | 13 +- sdks/urbackend-react/dist/index.js.map | 2 +- sdks/urbackend-react/dist/index.mjs | 15 +- sdks/urbackend-react/dist/index.mjs.map | 2 +- 51 files changed, 1707 insertions(+), 526 deletions(-) create mode 100644 apps/dashboard-api/src/controllers/invitation.controller.js create mode 100644 apps/dashboard-api/src/middlewares/authorizeProject.js create mode 100644 apps/dashboard-api/src/routes/invitations.js create mode 100644 apps/web-dashboard/src/pages/TeamMembers.jsx create mode 100644 packages/common/src/models/Invitation.js create mode 100644 packages/common/src/utils/projectAccess.js diff --git a/apps/dashboard-api/src/__tests__/auth.controller.test.js b/apps/dashboard-api/src/__tests__/auth.controller.test.js index 372e1cee5..2b68e68e1 100644 --- a/apps/dashboard-api/src/__tests__/auth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/auth.controller.test.js @@ -242,7 +242,7 @@ describe('auth.controller', () => { expect(next).toHaveBeenCalledWith(expect.any(AppError)); expect(next.mock.calls[0][0].statusCode).toBe(400); - expect(next.mock.calls[0][0].message).toBe('User not found'); + expect(next.mock.calls[0][0].message).toBe('Invalid email or password'); }); test('returns 400 on invalid password', async () => { @@ -256,7 +256,7 @@ describe('auth.controller', () => { expect(next).toHaveBeenCalledWith(expect.any(AppError)); expect(next.mock.calls[0][0].statusCode).toBe(400); - expect(next.mock.calls[0][0].message).toBe('Invalid password'); + expect(next.mock.calls[0][0].message).toBe('Invalid email or password'); }); test('returns 400 on Zod validation error (missing password)', async () => { diff --git a/apps/dashboard-api/src/__tests__/loadProjectForAdmin.test.js b/apps/dashboard-api/src/__tests__/loadProjectForAdmin.test.js index a2f734968..1330f75e3 100644 --- a/apps/dashboard-api/src/__tests__/loadProjectForAdmin.test.js +++ b/apps/dashboard-api/src/__tests__/loadProjectForAdmin.test.js @@ -11,7 +11,8 @@ jest.mock('@urbackend/common', () => ({ AppError, Project: { findOne: jest.fn() - } + }, + getProjectAccessQuery: jest.fn((userId) => ({ owner: userId })) })); const { Project } = require('@urbackend/common'); diff --git a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js index 0b83c2a04..a5d7ceef9 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.softDelete.test.js @@ -24,7 +24,8 @@ jest.mock('@urbackend/common', () => ({ this.statusCode = statusCode; this.isOperational = true; } - } + }, + getProjectAccessQuery: jest.fn((userId) => ({ owner: userId })) })); const { deleteRow, recoverRow } = require('../controllers/project.controller'); diff --git a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js index 3cbc5c314..5a31854dc 100644 --- a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js +++ b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js @@ -15,14 +15,18 @@ jest.mock('../middlewares/planEnforcement', () => ({ checkByodGate: jest.fn((_req, _res, next) => next()), checkWebhookGate: jest.fn((_req, _res, next) => next()), checkMailTemplatesGate: jest.fn((_req, _res, next) => next()), + checkMemberLimit: jest.fn((_req, _res, next) => next()), })); jest.mock('@urbackend/common', () => ({ verifyEmail: jest.fn((_req, _res, next) => next()), checkAuthEnabled: jest.fn((_req, _res, next) => next()), - loadProjectForAdmin: jest.fn((_req, _res, next) => next()), })); +jest.mock('../middlewares/authorizeProject', () => + jest.fn(() => (req, res, next) => next()) +); + jest.mock('../controllers/userAuth.controller', () => ({ createAdminUser: jest.fn((_req, res) => res.json({ ok: true })), resetPassword: jest.fn((_req, res) => res.json({ ok: true })), @@ -76,6 +80,10 @@ jest.mock('../controllers/project.controller', () => { manageContacts: jest.fn(ok), deleteContact: jest.fn(ok), sendMarketingBroadcast: jest.fn(ok), + getMembers: jest.fn(ok), + inviteMember: jest.fn(ok), + updateMemberRole: jest.fn(ok), + removeMember: jest.fn(ok), }; }); @@ -84,7 +92,8 @@ const request = require('supertest'); const projectsRouter = require('../routes/projects'); const projectController = require('../controllers/project.controller'); const authMiddleware = require('../middlewares/authMiddleware'); -const { verifyEmail, loadProjectForAdmin } = require('@urbackend/common'); +const { verifyEmail } = require('@urbackend/common'); +const authorizeProject = require('../middlewares/authorizeProject'); let app; @@ -112,7 +121,6 @@ describe('projects storage presigned routes', () => { expect(res.status).toBe(200); expect(authMiddleware).toHaveBeenCalled(); expect(verifyEmail).toHaveBeenCalled(); - expect(loadProjectForAdmin).toHaveBeenCalled(); expect(projectController.requestUpload).toHaveBeenCalledTimes(1); }); @@ -124,7 +132,6 @@ describe('projects storage presigned routes', () => { expect(res.status).toBe(200); expect(authMiddleware).toHaveBeenCalled(); expect(verifyEmail).toHaveBeenCalled(); - expect(loadProjectForAdmin).toHaveBeenCalled(); expect(projectController.confirmUpload).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/dashboard-api/src/__tests__/storage.presigned.controller.test.js b/apps/dashboard-api/src/__tests__/storage.presigned.controller.test.js index 54145c8b4..d71b2fd11 100644 --- a/apps/dashboard-api/src/__tests__/storage.presigned.controller.test.js +++ b/apps/dashboard-api/src/__tests__/storage.presigned.controller.test.js @@ -51,6 +51,7 @@ jest.mock('@urbackend/common', () => { return normalized; }), AppError, + getProjectAccessQuery: jest.fn((userId) => ({ owner: userId })), __mockStorageFrom: mockStorageFrom, }; }); diff --git a/apps/dashboard-api/src/__tests__/webhook.controller.test.js b/apps/dashboard-api/src/__tests__/webhook.controller.test.js index f022982f7..723f2c4a1 100644 --- a/apps/dashboard-api/src/__tests__/webhook.controller.test.js +++ b/apps/dashboard-api/src/__tests__/webhook.controller.test.js @@ -51,6 +51,7 @@ jest.mock('@urbackend/common', () => { }); } }, + getProjectAccessQuery: jest.fn((userId) => ({ owner: userId })), }; }); diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index b93c37a1d..06eb3ee0c 100644 --- a/apps/dashboard-api/src/app.js +++ b/apps/dashboard-api/src/app.js @@ -106,6 +106,7 @@ const billingRoute = require('./routes/billing'); const eventsRoute = require('./routes/events'); const adminMetricsRoute = require('./routes/admin.metrics'); const aiRoute = require('./routes/ai.routes'); +const invitationsRoute = require('./routes/invitations'); app.use('/api/auth', authRoute); app.use('/api/projects', dashboardLimiter, projectRoute); @@ -116,6 +117,7 @@ app.use('/api/analytics', dashboardLimiter, analyticsRoute); app.use('/api/billing', billingRoute); app.use('/api/events', dashboardLimiter, eventsRoute); app.use('/api/admin/metrics', dashboardLimiter, adminMetricsRoute); +app.use('/api/invitations', dashboardLimiter, invitationsRoute); diff --git a/apps/dashboard-api/src/controllers/ai.controller.js b/apps/dashboard-api/src/controllers/ai.controller.js index 3f6cf03be..8acc33c60 100644 --- a/apps/dashboard-api/src/controllers/ai.controller.js +++ b/apps/dashboard-api/src/controllers/ai.controller.js @@ -1,6 +1,6 @@ const { Project } = require('@urbackend/common/src/models'); const { forwardToPythonService } = require('../utils/internalPythonClient'); -const { AppError, ApiResponse } = require('@urbackend/common'); +const { AppError, ApiResponse, getProjectAccessQuery } = require('@urbackend/common'); /** * Controller to handle AI Query Builder requests. @@ -35,7 +35,7 @@ const queryBuilder = async (req, res, next) => { // 1. Fetch the project and specifically the requested collection schema const project = await Project.findOne( - { _id: projectId, owner: req.user._id, "collections.name": safeCollectionName }, + { ...getProjectAccessQuery(req.user._id), _id: projectId, "collections.name": safeCollectionName }, { "collections.$": 1 } ); diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index 3689379d1..ed13a1d0a 100644 --- a/apps/dashboard-api/src/controllers/analytics.controller.js +++ b/apps/dashboard-api/src/controllers/analytics.controller.js @@ -1,4 +1,4 @@ -const { Project, Log, Developer, Webhook, getConnection, resolveEffectivePlan, getPlanLimits, PlatformEvent, DeveloperActivity, AppError, ApiResponse } = require("@urbackend/common"); +const { Project, Log, Developer, Webhook, getConnection, resolveEffectivePlan, getPlanLimits, PlatformEvent, DeveloperActivity, AppError, ApiResponse, getProjectAccessQuery } = require("@urbackend/common"); const mongoose = require("mongoose"); /** @@ -12,12 +12,7 @@ module.exports.getGlobalStats = async (req, res, next) => { const [stats, dev] = await Promise.all([ Project.aggregate([ { - $match: { - $or: [ - { owner: user_id }, - { owner: userId } - ] - } + $match: { owner: userId }, }, { $group: { @@ -90,7 +85,7 @@ module.exports.getGlobalStats = async (req, res, next) => { module.exports.getRecentActivity = async (req, res, next) => { try { const userId = req.user._id; - const projectIds = await Project.find({ owner: userId }).distinct("_id"); + const projectIds = await Project.find(getProjectAccessQuery(userId)).distinct("_id"); const logs = await Log.find({ projectId: { $in: projectIds } }) .sort({ timestamp: -1 }) @@ -271,8 +266,8 @@ module.exports.getNorthStar = async (req, res, next) => { const sevenDaysAgo = new Date(); sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); - // Projects owned by this developer - const allProjects = await Project.find({ owner: developerId }).select('_id name').lean(); + // Projects accessible by this developer (owned or member) + const allProjects = await Project.find(getProjectAccessQuery(developerId)).select('_id name').lean(); const projectIds = allProjects.map((p) => p._id); const totalProjects = projectIds.length; diff --git a/apps/dashboard-api/src/controllers/auth.controller.js b/apps/dashboard-api/src/controllers/auth.controller.js index 3ca4b2b38..1bcc5255c 100644 --- a/apps/dashboard-api/src/controllers/auth.controller.js +++ b/apps/dashboard-api/src/controllers/auth.controller.js @@ -320,10 +320,10 @@ module.exports.login = async (req, res, next) => { const { email, password } = loginSchema.parse(req.body); const dev = await Developer.findOne({ email: email.toLowerCase().trim() }).select('+password'); - if (!dev) return next(new AppError(400, "User not found")); + if (!dev) return next(new AppError(400, "Invalid email or password")); const validPass = await bcrypt.compare(password, dev.password); - if (!validPass) return next(new AppError(400, "Invalid password")); + if (!validPass) return next(new AppError(400, "Invalid email or password")); await sendTokenResponse(dev, 200, res); } catch (err) { diff --git a/apps/dashboard-api/src/controllers/dbExport.controller.js b/apps/dashboard-api/src/controllers/dbExport.controller.js index ec7d52bf7..51f3b2c3f 100644 --- a/apps/dashboard-api/src/controllers/dbExport.controller.js +++ b/apps/dashboard-api/src/controllers/dbExport.controller.js @@ -1,4 +1,4 @@ -const { AppError, ApiResponse } = require('@urbackend/common'); +const { AppError, ApiResponse, getProjectRole } = require('@urbackend/common'); const { Developer } = require('@urbackend/common'); const { Project } = require('@urbackend/common'); const { exportQueue } = require('@urbackend/common'); @@ -19,8 +19,8 @@ module.exports.dbExportHandler = async (req, res, next) => { await setProjectById(projectId, project); } - if (project.owner.toString() !== userId.toString()) { - return next(new AppError(403, "Access denied. You are not the owner of this project.")); + if (!getProjectRole(project, userId)) { + return next(new AppError(403, "Access denied. You are not a member of this project.")); } if (!project.collections.some(c => c.name === collectionName)) { diff --git a/apps/dashboard-api/src/controllers/invitation.controller.js b/apps/dashboard-api/src/controllers/invitation.controller.js new file mode 100644 index 000000000..7847d5622 --- /dev/null +++ b/apps/dashboard-api/src/controllers/invitation.controller.js @@ -0,0 +1,147 @@ +const { Invitation, Project, Developer, AppError, ApiResponse, getProjectAccessQuery, deleteProjectById, resolveEffectivePlan, getPlanLimits } = require("@urbackend/common"); + +/** + * GET /api/invitations + * Returns all pending invitations for the currently logged-in developer. + */ +module.exports.getMyInvitations = async (req, res, next) => { + try { + const invitations = await Invitation.find({ + invitee: req.user._id, + status: "pending", + expiresAt: { $gt: new Date() }, + }) + .populate("project", "name description") + .populate("inviter", "email name") + .lean(); + + const formatted = invitations.map((inv) => ({ + _id: inv._id, + project: { + _id: inv.project._id, + name: inv.project.name, + description: inv.project.description, + }, + invitedBy: { + email: inv.inviter.email, + name: inv.inviter.name, + }, + role: inv.role, + expiresAt: inv.expiresAt, + createdAt: inv.createdAt, + })); + + return new ApiResponse(formatted).send(res); + } catch (err) { + next(err); + } +}; + +/** + * POST /api/invitations/:id/accept + * Invitee accepts — gets added to project.members[]. + */ +module.exports.acceptInvitation = async (req, res, next) => { + try { + const { id } = req.params; + + const invitation = await Invitation.findOne({ + _id: id, + invitee: req.user._id, + status: "pending", + expiresAt: { $gt: new Date() }, + }).lean(); + + if (!invitation) { + return next(new AppError(404, "Invitation not found, already used, or expired")); + } + + // Check the member limit is not exceeded + const project = await Project.findById(invitation.project).select("owner members customLimits").lean(); + if (!project) return next(new AppError(404, "Project no longer exists")); + + // Make sure invitee is not already a member (edge case — duplicate accept) + const alreadyMember = project.members?.some( + (m) => m.user.toString() === req.user._id.toString() + ); + if (alreadyMember) { + // Silently clean up and tell them they're in + await Invitation.findByIdAndUpdate(id, { status: "accepted" }); + return new ApiResponse( + { projectId: project._id }, + "You are already a member of this project", + ).send(res); + } + + const ownerDev = await Developer.findById(project.owner).lean(); + const limits = getPlanLimits(resolveEffectivePlan(ownerDev.plan, project.customLimits)); + const maxNonOwnerMembersAllowed = limits.maxMembers - 1; // Owner counts as 1 + + // Add member to project atomically and check limit + const updatedProject = await Project.findOneAndUpdate( + { + _id: invitation.project, + $expr: { + $lt: [ + { $size: { $ifNull: ["$members", []] } }, + maxNonOwnerMembersAllowed + ] + } + }, + { + $push: { + members: { + user: req.user._id, + role: invitation.role, + addedAt: new Date(), + }, + }, + }, + { new: true } + ); + + if (!updatedProject) { + return next(new AppError(400, "Project member limit reached or project not found")); + } + + await Invitation.findByIdAndUpdate(id, { status: "accepted" }); + + // INVALIDATE CACHE + await deleteProjectById(invitation.project.toString()).catch(() => {}); + + return new ApiResponse( + { projectId: invitation.project, role: invitation.role }, + "Invitation accepted — you are now a member of the project", + ).send(res, 200); + } catch (err) { + next(err); + } +}; + +/** + * POST /api/invitations/:id/decline + * Invitee declines — invitation is marked as declined. + */ +module.exports.declineInvitation = async (req, res, next) => { + try { + const { id } = req.params; + + const invitation = await Invitation.findOneAndUpdate( + { + _id: id, + invitee: req.user._id, + status: "pending", + expiresAt: { $gt: new Date() }, + }, + { status: "declined" }, + ); + + if (!invitation) { + return next(new AppError(404, "Invitation not found, already used, or expired")); + } + + return new ApiResponse({}, "Invitation declined").send(res); + } catch (err) { + next(err); + } +}; diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 493c3deec..5beec74a5 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -38,6 +38,7 @@ const { verifyUploadedFile } = require("@urbackend/common"); const { getPublicIp } = require("@urbackend/common"); const { clearCompiledModel } = require("@urbackend/common"); const { createUniqueIndexes, ApiAnalytics, MailLog } = require("@urbackend/common"); +const { getProjectAccessQuery, getProjectRole, Invitation } = require("@urbackend/common"); const { emitEvent } = require('../utils/emitEvent'); const MAX_FILE_SIZE = 10 * 1024 * 1024; const SAFETY_MAX_BYTES = 100 * 1024 * 1024; @@ -328,8 +329,8 @@ module.exports.createProject = async (req, res) => { module.exports.getAllProject = async (req, res) => { try { - const projects = await Project.find({ owner: req.user._id }) - .select("name description databaseUsed databaseLimit storageUsed storageLimit updatedAt isAuthEnabled collections") + const projects = await Project.find(getProjectAccessQuery(req.user._id)) + .select("name description databaseUsed databaseLimit storageUsed storageLimit updatedAt isAuthEnabled collections owner members") .lean(); const projectIds = projects.map(p => p._id); @@ -397,7 +398,7 @@ module.exports.getSingleProject = async (req, res) => { if (!projectObj) { const project = await Project.findOne({ _id: req.params.projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select( "-publishableKey -secretKey -jwtSecret " + "+authProviders.github.clientSecret.encrypted " + @@ -415,7 +416,7 @@ module.exports.getSingleProject = async (req, res) => { await setProjectById(req.params.projectId, projectObj); } - if (projectObj.owner.toString() !== req.user._id.toString()) { + if (!getProjectRole(projectObj, req.user._id)) { return res.status(403).json({ error: "Access denied." }); } @@ -441,7 +442,7 @@ module.exports.regenerateApiKey = async (req, res) => { const oldApiProj = await Project.findOne({ _id: req.params.projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select("publishableKey secretKey"); if (!oldApiProj) return res.status(404).json({ error: "Project not found." }); @@ -455,7 +456,7 @@ module.exports.regenerateApiKey = async (req, res) => { : { secretKey: hashed }; const project = await Project.findOneAndUpdate( - { _id: req.params.projectId, owner: req.user._id }, + { _id: req.params.projectId, ...getProjectAccessQuery(req.user._id) }, { $set: updateField }, { new: true }, ); @@ -658,7 +659,7 @@ module.exports.updateExternalConfig = async (req, res) => { } const project = await Project.findOneAndUpdate( - { _id: projectId, owner: req.user._id }, + { _id: projectId, ...getProjectAccessQuery(req.user._id) }, { $set: updateData }, { new: true }, ); @@ -688,7 +689,7 @@ module.exports.deleteExternalDbConfig = async (req, res) => { const project = await Project.findOne({ _id: { $eq: projectId }, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) return res @@ -718,7 +719,7 @@ module.exports.deleteExternalStorageConfig = async (req, res) => { const project = await Project.findOne({ _id: { $eq: projectId }, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) return res @@ -746,7 +747,7 @@ module.exports.createCollection = async (req, res) => { const projectQuery = Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (session) projectQuery.session(session); @@ -877,7 +878,7 @@ module.exports.createCollection = async (req, res) => { module.exports.getData = async (req, res) => { try { const { projectId, collectionName } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }); if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); @@ -975,7 +976,7 @@ module.exports.deleteCollection = async (req, res) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) { return res @@ -1023,7 +1024,7 @@ module.exports.insertData = async (req, res) => { const { projectId, collectionName } = req.params; const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) return res.status(404).json({ error: "Project not found." }); @@ -1103,7 +1104,7 @@ module.exports.deleteRow = async (req, res, next) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) return next(new AppError(404, "Project not found.")); @@ -1167,7 +1168,7 @@ module.exports.recoverRow = async (req, res, next) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).lean(); if (!project) { return next(new AppError(404, "Project not found.")); @@ -1237,7 +1238,7 @@ module.exports.editRow = async (req, res) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) return res.status(404).json({ error: "Project not found." }); @@ -1326,7 +1327,7 @@ module.exports.listFiles = async (req, res) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select( "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", ); @@ -1375,7 +1376,7 @@ module.exports.deleteFile = async (req, res) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select( "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", ); @@ -1421,7 +1422,7 @@ module.exports.deleteAllFiles = async (req, res) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select( "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", ); @@ -1484,7 +1485,7 @@ module.exports.requestUpload = async (req, res, next) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select( "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", ); @@ -1542,7 +1543,7 @@ module.exports.confirmUpload = async (req, res, next) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select( "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", ); @@ -1706,7 +1707,7 @@ module.exports.updateProject = async (req, res) => { } const project = await Project.findOneAndUpdate( - { _id: req.params.projectId, owner: req.user._id }, + { _id: req.params.projectId, ...getProjectAccessQuery(req.user._id) }, { $set: updateFields }, { new: true, @@ -1746,7 +1747,7 @@ module.exports.listMailTemplates = async (req, res, next) => { try { const { projectId } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+mailTemplates"); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }).select("+mailTemplates"); if (!project) return res.status(404).json({ success: false, data: {}, message: "Project not found." }); @@ -1864,7 +1865,7 @@ module.exports.listGlobalMailTemplates = async (req, res, next) => { try { const { projectId } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }) .select("_id") .lean(); @@ -1902,7 +1903,7 @@ module.exports.getMailTemplate = async (req, res, next) => { return res.status(400).json({ success: false, data: {}, message: "Invalid template id" }); } - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }) .select("+mailTemplates") .lean(); @@ -1979,7 +1980,7 @@ module.exports.createMailTemplate = async (req, res, next) => { const payload = schema.parse(req.body || {}); - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }) .select("_id") .lean(); @@ -2042,7 +2043,7 @@ module.exports.updateMailTemplate = async (req, res, next) => { const payload = schema.parse(req.body || {}); - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }) .select("_id") .lean(); @@ -2116,7 +2117,7 @@ module.exports.deleteMailTemplate = async (req, res, next) => { return res.status(400).json({ success: false, data: {}, message: "Invalid template id" }); } - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }) .select("_id") .lean(); @@ -2156,7 +2157,7 @@ module.exports.updateAllowedDomains = async (req, res) => { .filter((d) => d.length > 0); const project = await Project.findOneAndUpdate( - { _id: req.params.projectId, owner: req.user._id }, + { _id: req.params.projectId, ...getProjectAccessQuery(req.user._id) }, { $set: { allowedDomains: cleanedDomains } }, { new: true }, ); @@ -2251,7 +2252,7 @@ module.exports.analytics = async (req, res, next) => { const { projectId } = req.params; const { range = 'last24h' } = req.query; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }); if (!project) { return res.status(404).json({ success: false, @@ -2413,7 +2414,7 @@ module.exports.toggleAuth = async (req, res) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select( "+authProviders.github.clientSecret.encrypted " + "+authProviders.github.clientSecret.iv " + @@ -2469,7 +2470,7 @@ module.exports.updateAuthProviders = async (req, res) => { const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }).select( "+authProviders.github.clientSecret.encrypted " + "+authProviders.github.clientSecret.iv " + @@ -2542,7 +2543,7 @@ module.exports.updateCollectionRls = async (req, res) => { const { projectId, collectionName } = req.params; const { enabled, mode, ownerField, requireAuthForWrite } = req.body || {}; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }); if (!project) return res.status(404).json({ error: "Project not found" }); const collection = project.collections.find(c => c.name === collectionName); @@ -2625,7 +2626,7 @@ const getResolvedResendKey = (project) => { module.exports.getMailLogs = async (req, res) => { try { const { projectId } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }); if (!project) return res.status(404).json({ success: false, message: "Project not found" }); const logs = await MailLog.find({ projectId: project._id }) @@ -2646,7 +2647,7 @@ module.exports.getResendLiveStatus = async (req, res) => { return res.status(400).json({ success: false, message: "Invalid resendId format." }); } - const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); if (!project) return res.status(404).json({ success: false, message: "Project not found" }); const logEntry = await MailLog.findOne({ resendEmailId: resendId, projectId: project._id }).lean(); @@ -2684,7 +2685,7 @@ module.exports.getResendLiveStatus = async (req, res) => { module.exports.manageAudiences = async (req, res) => { try { const { projectId } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); if (!project) return res.status(404).json({ success: false, message: "Project not found" }); const { key, isByok } = getResolvedResendKey(project); @@ -2723,7 +2724,7 @@ module.exports.deleteAudience = async (req, res) => { return res.status(400).json({ success: false, message: "Invalid audienceId format" }); } const safeAudienceId = encodeURIComponent(audienceId); - const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); if (!project) return res.status(404).json({ success: false, message: "Project not found" }); const { key, isByok } = getResolvedResendKey(project); @@ -2749,7 +2750,7 @@ module.exports.manageContacts = async (req, res) => { return res.status(400).json({ success: false, message: "Invalid audienceId format" }); } const safeAudienceId = encodeURIComponent(audienceId); - const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); if (!project) return res.status(404).json({ success: false, message: "Project not found" }); const { key, isByok } = getResolvedResendKey(project); @@ -2785,7 +2786,7 @@ module.exports.manageContacts = async (req, res) => { module.exports.deleteContact = async (req, res) => { try { const { projectId, audienceId, contactId } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); if (!project) return res.status(404).json({ success: false, message: "Project not found" }); const { key, isByok } = getResolvedResendKey(project); @@ -2817,7 +2818,7 @@ module.exports.sendMarketingBroadcast = async (req, res) => { const { projectId } = req.params; const { audienceId, subject, html, from } = req.body; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); + const project = await Project.findOne({ _id: projectId, ...getProjectAccessQuery(req.user._id) }).select("+resendApiKey.encrypted +resendApiKey.iv +resendApiKey.tag"); if (!project) return res.status(404).json({ success: false, message: "Project not found" }); const { key, isByok } = getResolvedResendKey(project); @@ -2852,3 +2853,217 @@ module.exports.sendMarketingBroadcast = async (req, res) => { return res.status(err.response?.status || 500).json({ success: false, message: errorMsg }); } }; + +// ───────────────────────────────────────────────────────────────────────────── +// TEAM MEMBER MANAGEMENT +// ───────────────────────────────────────────────────────────────────────────── + +/** + * GET /:projectId/members + * Returns the member list populated with email and name. + * Accessible by owner and all members. + */ +module.exports.getMembers = async (req, res, next) => { + try { + const { projectId } = req.params; + const project = await Project.findOne({ + _id: projectId, + ...getProjectAccessQuery(req.user._id), + }) + .select("owner members") + .populate("owner", "email name") + .populate("members.user", "email name") + .lean(); + + if (!project) return next(new AppError(404, "Project not found or access denied")); + + const ownerEntry = { + _id: project.owner._id, + email: project.owner.email, + name: project.owner.name, + role: "owner", + }; + + const memberEntries = (project.members || []).map((m) => ({ + _id: m.user._id, + email: m.user.email, + name: m.user.name, + role: m.role, + addedAt: m.addedAt, + })); + + return res.json({ + success: true, + data: { members: [ownerEntry, ...memberEntries] }, + message: "", + }); + } catch (err) { + next(err); + } +}; + +/** + * POST /:projectId/members/invite + * Owner-only. Invites a developer by email (must already have an account). + * Creates a pending Invitation and sends an invite email. + */ +module.exports.inviteMember = async (req, res, next) => { + try { + const { projectId } = req.params; + const { email, role = "admin" } = req.body; + + if (!email || typeof email !== "string") { + return next(new AppError(400, "A valid email address is required")); + } + if (!["admin", "viewer"].includes(role)) { + return next(new AppError(400, "Role must be 'admin' or 'viewer'")); + } + + // Only owner can invite + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }) + .select("owner members name") + .lean(); + + if (!project) return next(new AppError(404, "Project not found or access denied")); + + const role_ = getProjectRole(project, req.user._id); + if (role_ !== "owner") { + return next(new AppError(403, "Only the project owner can invite members")); + } + + const normalizedEmail = email.trim().toLowerCase(); + + // Check invitee has an account + const invitee = await Developer.findOne({ email: normalizedEmail }).select("_id email name").lean(); + if (!invitee) { + return next(new AppError(404, "No urBackend account found with this email address")); + } + + // Cannot invite yourself + if (invitee._id.toString() === req.user._id.toString()) { + return next(new AppError(400, "You cannot invite yourself")); + } + + // Cannot invite someone already a member + const alreadyMember = project.members?.some( + (m) => m.user.toString() === invitee._id.toString() + ); + if (alreadyMember) { + return next(new AppError(409, "This developer is already a member of this project")); + } + + // Cannot invite if there's already a pending invite for them + const existingInvite = await Invitation.findOne({ + project: projectId, + invitee: invitee._id, + status: "pending", + }).lean(); + if (existingInvite) { + return next(new AppError(409, "A pending invitation already exists for this developer")); + } + + // Create the invitation (7-day expiry) + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + await Invitation.create({ + project: projectId, + inviter: req.user._id, + invitee: invitee._id, + role, + status: "pending", + expiresAt, + }); + + // TODO: Send invite notification email here once email template is ready + // await sendInviteEmail({ to: invitee.email, projectName: project.name, role, inviterEmail: req.user.email }); + + return res.status(201).json({ + success: true, + data: { email: invitee.email, role, expiresAt }, + message: `Invitation sent to ${invitee.email}`, + }); + } catch (err) { + next(err); + } +}; + +/** + * PATCH /:projectId/members/:memberId/role + * Owner-only. Changes a member's role. + */ +module.exports.updateMemberRole = async (req, res, next) => { + try { + const { projectId, memberId } = req.params; + const { role } = req.body; + + if (!["admin", "viewer"].includes(role)) { + return next(new AppError(400, "Role must be 'admin' or 'viewer'")); + } + + if (req.user._id.toString() === memberId) { + return next(new AppError(403, "You cannot modify your own role")); + } + + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + "members.user": memberId, + }); + + if (!project) return next(new AppError(404, "Project or member not found")); + + const member = project.members.find((m) => m.user.toString() === memberId); + if (!member) return next(new AppError(404, "Member not found")); + + member.role = role; + await project.save(); + await deleteProjectById(projectId); + + return res.json({ + success: true, + data: { memberId, role }, + message: "Member role updated", + }); + } catch (err) { + next(err); + } +}; + +/** + * DELETE /:projectId/members/:memberId + * Admin/Owner-only. Removes a member from the project. + */ +module.exports.removeMember = async (req, res, next) => { + try { + const { projectId, memberId } = req.params; + + if (req.user._id.toString() === memberId) { + return next(new AppError(403, "You cannot remove yourself")); + } + + const project = await Project.findOneAndUpdate( + { _id: projectId, owner: req.user._id }, + { $pull: { members: { user: memberId } } }, + { new: true }, + ); + + if (!project) return next(new AppError(404, "Project not found or access denied")); + + // Also clean up any pending invitations for this member + await Invitation.deleteMany({ project: projectId, invitee: memberId }); + + // Invalidate project cache + await deleteProjectById(projectId); + + return res.json({ + success: true, + data: {}, + message: "Member removed from project", + }); + } catch (err) { + next(err); + } +}; + diff --git a/apps/dashboard-api/src/controllers/webhook.controller.js b/apps/dashboard-api/src/controllers/webhook.controller.js index b2762f1fd..e09777627 100644 --- a/apps/dashboard-api/src/controllers/webhook.controller.js +++ b/apps/dashboard-api/src/controllers/webhook.controller.js @@ -10,6 +10,7 @@ const { createWebhookSchema, updateWebhookSchema, generateSignature, + getProjectAccessQuery, } = require("@urbackend/common"); const crypto = require("crypto"); @@ -27,10 +28,10 @@ module.exports.createWebhook = async (req, res, next) => { return next(new AppError(400, "Invalid project ID")); } - // Verify project ownership + // Verify project access (owner or member) const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) { return next(new AppError(404, "Project not found")); @@ -82,10 +83,10 @@ module.exports.getWebhooks = async (req, res, next) => { return next(new AppError(400, "Invalid project ID")); } - // Verify project ownership + // Verify project access (owner or member) const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) { return next(new AppError(404, "Project not found")); @@ -122,10 +123,10 @@ module.exports.getWebhook = async (req, res, next) => { return next(new AppError(400, "Invalid ID format")); } - // Verify project ownership + // Verify project access (owner or member) const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) { return next(new AppError(404, "Project not found")); @@ -166,10 +167,10 @@ module.exports.updateWebhook = async (req, res, next) => { return next(new AppError(400, "Invalid ID format")); } - // Verify project ownership + // Verify project access (owner or member) const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) { return next(new AppError(404, "Project not found")); @@ -230,10 +231,10 @@ module.exports.deleteWebhook = async (req, res, next) => { return next(new AppError(400, "Invalid ID format")); } - // Verify project ownership + // Verify project access (owner or member) const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) { return next(new AppError(404, "Project not found")); @@ -269,10 +270,10 @@ module.exports.getDeliveries = async (req, res, next) => { return next(new AppError(400, "Invalid ID format")); } - // Verify project ownership + // Verify project access (owner or member) const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) { return next(new AppError(404, "Project not found")); @@ -322,10 +323,10 @@ module.exports.testWebhook = async (req, res, next) => { return next(new AppError(400, "Invalid ID format")); } - // Verify project ownership + // Verify project access (owner or member) const project = await Project.findOne({ _id: projectId, - owner: req.user._id, + ...getProjectAccessQuery(req.user._id), }); if (!project) { return next(new AppError(404, "Project not found")); diff --git a/apps/dashboard-api/src/middlewares/authorizeProject.js b/apps/dashboard-api/src/middlewares/authorizeProject.js new file mode 100644 index 000000000..84495d231 --- /dev/null +++ b/apps/dashboard-api/src/middlewares/authorizeProject.js @@ -0,0 +1,51 @@ +const { Project, AppError } = require("@urbackend/common"); +const { getProjectAccessQuery, getProjectRole } = require("@urbackend/common"); + +/** + * Middleware factory: loads a project by ID, checks owner OR member access, + * and attaches req.project + req.projectRole. + * + * Usage: + * authorizeProject() — any member or owner can proceed + * authorizeProject('admin') — owner or admin member required + * authorizeProject('owner') — only the project owner can proceed + * + * @param {'owner' | 'admin' | undefined} requiredRole + */ +module.exports = function authorizeProject(requiredRole) { + return async (req, res, next) => { + try { + const { projectId } = req.params; + if (!projectId) return next(new AppError(400, "Project ID is required")); + + const project = await Project.findOne({ + _id: projectId, + ...getProjectAccessQuery(req.user._id), + }); + + if (!project) { + return next(new AppError(404, "Project not found or access denied")); + } + + const role = getProjectRole(project, req.user._id); + req.project = project; + req.projectRole = role; + + if (requiredRole === "owner" && role !== "owner") { + return next( + new AppError(403, "Only the project owner can perform this action"), + ); + } + + if (requiredRole === "admin" && role === "viewer") { + return next( + new AppError(403, "Viewers do not have write access to this project"), + ); + } + + next(); + } catch (err) { + next(err); + } + }; +}; diff --git a/apps/dashboard-api/src/middlewares/loadProjectForAdmin.js b/apps/dashboard-api/src/middlewares/loadProjectForAdmin.js index 9bc03c32e..a1ad2b5c5 100644 --- a/apps/dashboard-api/src/middlewares/loadProjectForAdmin.js +++ b/apps/dashboard-api/src/middlewares/loadProjectForAdmin.js @@ -1,13 +1,16 @@ // FUNCTION - LOAD PROJECT FOR ADMIN (MIDDLEWARE) -const { Project, AppError } = require('@urbackend/common'); +const { Project, AppError, getProjectAccessQuery } = require('@urbackend/common'); module.exports = async (req, res, next) => { try { const { projectId } = req.params; if (!projectId) return next(new AppError(400, "Project ID is required")); - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); + const project = await Project.findOne({ + _id: projectId, + ...getProjectAccessQuery(req.user._id), + }); if (!project) { return next(new AppError(404, "Project not found or access denied")); } diff --git a/apps/dashboard-api/src/middlewares/planEnforcement.js b/apps/dashboard-api/src/middlewares/planEnforcement.js index 89b0ccf25..2fc6f4575 100644 --- a/apps/dashboard-api/src/middlewares/planEnforcement.js +++ b/apps/dashboard-api/src/middlewares/planEnforcement.js @@ -3,12 +3,24 @@ const mongoose = require('mongoose'); console.log('DEBUG: planEnforcement.js exporting attachDeveloper'); exports.attachDeveloper = async function(req, res, next) { - const { Developer, AppError } = require('@urbackend/common'); + const { Developer, AppError, Project, sanitizeObjectId } = require('@urbackend/common'); try { if (!req.user || !req.user._id) { return next(new AppError(401, 'Unauthorized: Developer context missing')); } - const developer = await Developer.findById(req.user._id); + + let targetDeveloperId = req.user._id; + + const rawProjectId = req.params.projectId || req.body.projectId || req.query.projectId; + const cleanProjectId = sanitizeObjectId(rawProjectId); + if (cleanProjectId) { + const project = await Project.findById(cleanProjectId).select('owner').lean(); + if (project && project.owner) { + targetDeveloperId = project.owner; + } + } + + const developer = await Developer.findById(targetDeveloperId); if (!developer) return next(new AppError(404, 'Developer not found')); req.developer = developer; next(); @@ -34,14 +46,17 @@ exports.checkProjectLimit = async function(req, res, next) { console.log('DEBUG: planEnforcement.js exporting checkCollectionLimit'); exports.checkCollectionLimit = async function(req, res, next) { - const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId } = require('@urbackend/common'); + const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId, getProjectAccessQuery } = require('@urbackend/common'); try { if (req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()) return next(); const cleanProjectId = sanitizeObjectId(req.body.projectId); if (!cleanProjectId) return next(new AppError(400, 'Invalid or missing projectId')); - const project = await Project.findOne({ _id: cleanProjectId, owner: req.developer._id }); + const project = await Project.findOne({ + _id: cleanProjectId, + ...getProjectAccessQuery(req.developer._id), + }); if (!project) return next(new AppError(404, 'Project not found')); const effectivePlan = resolveEffectivePlan(req.developer); @@ -159,6 +174,40 @@ exports.checkMailTemplatesGate = async function(req, res, next) { return next(new AppError(403, 'Custom Mail Templates are a Pro feature. Please upgrade to customize your emails.')); } + next(); + } catch (err) { + next(err); + } +} + +console.log('DEBUG: planEnforcement.js exporting checkMemberLimit'); +exports.checkMemberLimit = async function(req, res, next) { + const { Project, resolveEffectivePlan, getPlanLimits, AppError, sanitizeObjectId, getProjectAccessQuery } = require('@urbackend/common'); + try { + if (req.user?.isAdmin || req.user?.email?.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()) return next(); + + const rawProjectId = req.params.projectId; + const cleanProjectId = sanitizeObjectId(rawProjectId); + if (!cleanProjectId) return next(new AppError(400, 'Invalid or missing projectId')); + + const project = await Project.findOne({ + _id: cleanProjectId, + ...getProjectAccessQuery(req.user._id), + }).select('members customLimits owner').lean(); + if (!project) return next(new AppError(404, 'Project not found')); + + const effectivePlan = resolveEffectivePlan(req.developer); + const limits = getPlanLimits({ plan: effectivePlan, customLimits: project.customLimits }); + + // maxMembers includes the owner; current count = 1 (owner) + members.length + const currentTotal = 1 + (project.members?.length || 0); + if (limits.maxMembers !== -1 && currentTotal >= limits.maxMembers) { + return next(new AppError( + 403, + `Member limit reached (${limits.maxMembers} total). Please upgrade your plan to add more team members.` + )); + } + next(); } catch (err) { next(err); diff --git a/apps/dashboard-api/src/routes/invitations.js b/apps/dashboard-api/src/routes/invitations.js new file mode 100644 index 000000000..966437019 --- /dev/null +++ b/apps/dashboard-api/src/routes/invitations.js @@ -0,0 +1,27 @@ +const express = require("express"); +const router = express.Router(); +const authMiddleware = require("../middlewares/authMiddleware"); +const invitation = require("../controllers/invitation.controller"); + +// All invitation routes require dashboard auth +router.use(authMiddleware); + +/** + * GET /api/invitations + * Get all pending invitations for the logged-in developer. + */ +router.get("/", invitation.getMyInvitations); + +/** + * POST /api/invitations/:id/accept + * Accept a pending invitation. + */ +router.post("/:id/accept", invitation.acceptInvitation); + +/** + * POST /api/invitations/:id/decline + * Decline a pending invitation. + */ +router.post("/:id/decline", invitation.declineInvitation); + +module.exports = router; diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index 06c8d4cf2..fdcb8355e 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -2,7 +2,8 @@ const express = require('express'); const router = express.Router(); const authMiddleware = require('../middlewares/authMiddleware'); const planEnforcement = require('../middlewares/planEnforcement'); -const { verifyEmail, checkAuthEnabled, loadProjectForAdmin } = require('@urbackend/common'); +const authorizeProject = require('../middlewares/authorizeProject'); +const { verifyEmail, checkAuthEnabled } = require('@urbackend/common'); const multer = require('multer'); const storage = multer.memoryStorage(); @@ -45,7 +46,11 @@ const { deleteAudience, manageContacts, deleteContact, - sendMarketingBroadcast + sendMarketingBroadcast, + getMembers, + inviteMember, + updateMemberRole, + removeMember } = require("../controllers/project.controller"); const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdminUsers, deleteAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller'); @@ -55,107 +60,112 @@ const exportController = require('../controllers/dbExport.controller'); // POST REQ FOR CREATE PROJECT router.post('/', authMiddleware, verifyEmail, planEnforcement.checkProjectLimit, createProject); router.get('/', authMiddleware, getAllProject); -router.get('/:projectId', authMiddleware, getSingleProject); -router.post('/:projectId/api-key', authMiddleware, verifyEmail, regenerateApiKey); +router.get('/:projectId', authMiddleware, authorizeProject(), getSingleProject); +router.post('/:projectId/api-key', authMiddleware, authorizeProject('admin'), verifyEmail, regenerateApiKey); -router.post('/:projectId/collections', authMiddleware, verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkCollectionLimit, createCollection); +router.post('/:projectId/collections', authMiddleware, authorizeProject('admin'), verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkCollectionLimit, createCollection); // DELETE REQ FOR COLLECTION -router.delete('/:projectId/collections/:collectionName', authMiddleware, verifyEmail, deleteCollection); +router.delete('/:projectId/collections/:collectionName', authMiddleware, authorizeProject('admin'), verifyEmail, deleteCollection); // GET REQ FOR DATA -router.get('/:projectId/collections/:collectionName/data', authMiddleware, getData); +router.get('/:projectId/collections/:collectionName/data', authMiddleware, authorizeProject(), getData); // DELETE REQ FOR ROW -router.delete('/:projectId/collections/:collectionName/data/:id', authMiddleware, deleteRow); +router.delete('/:projectId/collections/:collectionName/data/:id', authMiddleware, authorizeProject('admin'), deleteRow); // PATCH REQ FOR RECOVER ROW -router.patch('/:projectId/collections/:collectionName/data/:id/recover', authMiddleware, recoverRow); +router.patch('/:projectId/collections/:collectionName/data/:id/recover', authMiddleware, authorizeProject('admin'), recoverRow); // PATCH REQ FOR EDIT ROW -router.patch('/:projectId/collections/:collectionName/data/:id', authMiddleware, editRow); +router.patch('/:projectId/collections/:collectionName/data/:id', authMiddleware, authorizeProject('admin'), editRow); // GET REQ FOR FILES -router.get('/:projectId/storage/files', authMiddleware, listFiles); +router.get('/:projectId/storage/files', authMiddleware, authorizeProject(), listFiles); // POST REQ FOR DELETE FILE -router.post('/:projectId/storage/delete', authMiddleware, verifyEmail, deleteFile); +router.post('/:projectId/storage/delete', authMiddleware, authorizeProject('admin'), verifyEmail, deleteFile); //SIGNED URL -router.post('/:projectId/storage/upload-request', authMiddleware, verifyEmail, loadProjectForAdmin, requestUpload); +router.post('/:projectId/storage/upload-request', authMiddleware, authorizeProject('admin'), verifyEmail, requestUpload); //UPLOAD URL -router.post('/:projectId/storage/upload-confirm', authMiddleware, verifyEmail, loadProjectForAdmin, confirmUpload); +router.post('/:projectId/storage/upload-confirm', authMiddleware, authorizeProject('admin'), verifyEmail, confirmUpload); // DELETE REQ FOR PROJECT -router.delete('/:projectId', authMiddleware, verifyEmail, deleteProject); +router.delete('/:projectId', authMiddleware, authorizeProject('owner'), verifyEmail, deleteProject); // PATCH REQ FOR UPDATE PROJECT -router.patch('/:projectId', authMiddleware, planEnforcement.attachDeveloper, planEnforcement.checkByokGate, updateProject); +router.patch('/:projectId', authMiddleware, authorizeProject('admin'), planEnforcement.attachDeveloper, planEnforcement.checkByokGate, updateProject); // MAIL TEMPLATES (Phase 2) -router.get('/:projectId/mail/templates', authMiddleware, listMailTemplates); -router.get('/:projectId/mail/templates/global', authMiddleware, listGlobalMailTemplates); -router.get('/:projectId/mail/templates/:templateId', authMiddleware, getMailTemplate); -router.post('/:projectId/mail/templates', authMiddleware, verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkMailTemplatesGate, createMailTemplate); -router.patch('/:projectId/mail/templates/:templateId', authMiddleware, verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkMailTemplatesGate, updateMailTemplate); -router.delete('/:projectId/mail/templates/:templateId', authMiddleware, verifyEmail, deleteMailTemplate); +router.get('/:projectId/mail/templates', authMiddleware, authorizeProject(), listMailTemplates); +router.get('/:projectId/mail/templates/global', authMiddleware, authorizeProject(), listGlobalMailTemplates); +router.get('/:projectId/mail/templates/:templateId', authMiddleware, authorizeProject(), getMailTemplate); +router.post('/:projectId/mail/templates', authMiddleware, authorizeProject('admin'), verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkMailTemplatesGate, createMailTemplate); +router.patch('/:projectId/mail/templates/:templateId', authMiddleware, authorizeProject('admin'), verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkMailTemplatesGate, updateMailTemplate); +router.delete('/:projectId/mail/templates/:templateId', authMiddleware, authorizeProject('admin'), verifyEmail, deleteMailTemplate); // EXPANDED MAIL API PLATFORM PROXIES -router.get('/:projectId/mail/logs', authMiddleware, getMailLogs); -router.get('/:projectId/mail/logs/:resendId/live', authMiddleware, getResendLiveStatus); -router.get('/:projectId/mail/audiences', authMiddleware, manageAudiences); -router.post('/:projectId/mail/audiences', authMiddleware, verifyEmail, manageAudiences); -router.delete('/:projectId/mail/audiences/:audienceId', authMiddleware, verifyEmail, deleteAudience); -router.get('/:projectId/mail/audiences/:audienceId/contacts', authMiddleware, manageContacts); -router.post('/:projectId/mail/audiences/:audienceId/contacts', authMiddleware, verifyEmail, manageContacts); -router.delete('/:projectId/mail/audiences/:audienceId/contacts/:contactId', authMiddleware, verifyEmail, deleteContact); -router.post('/:projectId/mail/broadcasts', authMiddleware, verifyEmail, sendMarketingBroadcast); +router.get('/:projectId/mail/logs', authMiddleware, authorizeProject(), getMailLogs); +router.get('/:projectId/mail/logs/:resendId/live', authMiddleware, authorizeProject(), getResendLiveStatus); +router.get('/:projectId/mail/audiences', authMiddleware, authorizeProject(), manageAudiences); +router.post('/:projectId/mail/audiences', authMiddleware, authorizeProject('admin'), verifyEmail, manageAudiences); +router.delete('/:projectId/mail/audiences/:audienceId', authMiddleware, authorizeProject('admin'), verifyEmail, deleteAudience); +router.get('/:projectId/mail/audiences/:audienceId/contacts', authMiddleware, authorizeProject(), manageContacts); +router.post('/:projectId/mail/audiences/:audienceId/contacts', authMiddleware, authorizeProject('admin'), verifyEmail, manageContacts); +router.delete('/:projectId/mail/audiences/:audienceId/contacts/:contactId', authMiddleware, authorizeProject('admin'), verifyEmail, deleteContact); +router.post('/:projectId/mail/broadcasts', authMiddleware, authorizeProject('admin'), verifyEmail, sendMarketingBroadcast); // PATCH REQ FOR ALLOWED DOMAINS -router.patch('/:projectId/allowed-domains', authMiddleware, verifyEmail, updateAllowedDomains); +router.patch('/:projectId/allowed-domains', authMiddleware, authorizeProject('admin'), verifyEmail, updateAllowedDomains); // PATCH REQ FOR BYOD CONFIG -router.delete('/:projectId/byod-config/db', authMiddleware, deleteExternalDbConfig); +router.delete('/:projectId/byod-config/db', authMiddleware, authorizeProject('admin'), deleteExternalDbConfig); // DELETE REQ FOR BYOD STORAGE CONFIG -router.delete('/:projectId/byod-config/storage', authMiddleware, deleteExternalStorageConfig); +router.delete('/:projectId/byod-config/storage', authMiddleware, authorizeProject('admin'), deleteExternalStorageConfig); // POST REQ FOR INSERT DATA -router.post('/:projectId/collections/:collectionName/data', authMiddleware, verifyEmail, insertData); +router.post('/:projectId/collections/:collectionName/data', authMiddleware, authorizeProject('admin'), verifyEmail, insertData); // DELETE REQ FOR ALL FILES -router.delete('/:projectId/storage/files', authMiddleware, deleteAllFiles); +router.delete('/:projectId/storage/files', authMiddleware, authorizeProject('admin'), deleteAllFiles); // GET REQ FOR ANALYTICS -router.get('/:projectId/analytics', authMiddleware, analytics); +router.get('/:projectId/analytics', authMiddleware, authorizeProject(), analytics); // PATCH REQ FOR TOGGLE AUTH -router.patch('/:projectId/auth/toggle', authMiddleware, verifyEmail, toggleAuth); +router.patch('/:projectId/auth/toggle', authMiddleware, authorizeProject('admin'), verifyEmail, toggleAuth); // PATCH REQ FOR SOCIAL AUTH PROVIDERS -router.patch('/:projectId/auth/providers', authMiddleware, planEnforcement.attachDeveloper, verifyEmail, planEnforcement.checkByokGate, updateAuthProviders); +router.patch('/:projectId/auth/providers', authMiddleware, authorizeProject('admin'), planEnforcement.attachDeveloper, verifyEmail, planEnforcement.checkByokGate, updateAuthProviders); // PATCH REQ FOR BYOD CONFIG -router.patch('/:projectId/byod-config', authMiddleware, planEnforcement.attachDeveloper, planEnforcement.checkByodGate, updateExternalConfig); +router.patch('/:projectId/byod-config', authMiddleware, authorizeProject('admin'), planEnforcement.attachDeveloper, planEnforcement.checkByodGate, updateExternalConfig); // PATCH REQ FOR COLLECTION RLS SETTINGS -router.patch('/:projectId/collections/:collectionName/rls', authMiddleware, verifyEmail, updateCollectionRls); +router.patch('/:projectId/collections/:collectionName/rls', authMiddleware, authorizeProject('admin'), verifyEmail, updateCollectionRls); -// ADMIN AUTH ROUTES +// TEAM MEMBER MANAGEMENT +router.get('/:projectId/members', authMiddleware, authorizeProject(), getMembers); +router.post('/:projectId/members/invite', authMiddleware, authorizeProject('owner'), verifyEmail, planEnforcement.attachDeveloper, planEnforcement.checkMemberLimit, inviteMember); +router.patch('/:projectId/members/:memberId/role', authMiddleware, authorizeProject('owner'), verifyEmail, updateMemberRole); +router.delete('/:projectId/members/:memberId', authMiddleware, authorizeProject('owner'), verifyEmail, removeMember); +// ADMIN AUTH ROUTES -router.post('/:projectId/admin/users', authMiddleware, loadProjectForAdmin, checkAuthEnabled, createAdminUser); -router.patch('/:projectId/admin/users/:userId/password', authMiddleware, loadProjectForAdmin, checkAuthEnabled, resetPassword); -router.get('/:projectId/admin/users', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listAdminUsers); -router.get('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, getUserDetails); -router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, updateAdminUser); -router.delete('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, deleteAdminUser); +router.post('/:projectId/admin/users', authMiddleware, authorizeProject('admin'), checkAuthEnabled, createAdminUser); +router.patch('/:projectId/admin/users/:userId/password', authMiddleware, authorizeProject('admin'), checkAuthEnabled, resetPassword); +router.get('/:projectId/admin/users', authMiddleware, authorizeProject(), checkAuthEnabled, listAdminUsers); +router.get('/:projectId/admin/users/:userId', authMiddleware, authorizeProject(), checkAuthEnabled, getUserDetails); +router.put('/:projectId/admin/users/:userId', authMiddleware, authorizeProject('admin'), checkAuthEnabled, updateAdminUser); +router.delete('/:projectId/admin/users/:userId', authMiddleware, authorizeProject('admin'), checkAuthEnabled, deleteAdminUser); // SESSION MANAGEMENT (Admin) -router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listUserSessions); -router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, revokeUserSession); +router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, authorizeProject(), checkAuthEnabled, listUserSessions); +router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddleware, authorizeProject('admin'), checkAuthEnabled, revokeUserSession); // POST req for DB EXPORT -router.post('/:projectId/collections/:collectionName/export', authMiddleware, exportController.dbExportHandler); +router.post('/:projectId/collections/:collectionName/export', authMiddleware, authorizeProject(), exportController.dbExportHandler); module.exports = router; diff --git a/apps/dashboard-api/src/routes/webhooks.js b/apps/dashboard-api/src/routes/webhooks.js index 518a429bd..728a42b0b 100644 --- a/apps/dashboard-api/src/routes/webhooks.js +++ b/apps/dashboard-api/src/routes/webhooks.js @@ -13,26 +13,26 @@ const { testWebhook, } = require("../controllers/webhook.controller"); const { attachDeveloper, checkWebhookGate } = require("../middlewares/planEnforcement"); - +const authorizeProject = require("../middlewares/authorizeProject"); // Create webhook -router.post("/:projectId/webhooks", authMiddleware, verifyEmail, attachDeveloper, checkWebhookGate, createWebhook); +router.post("/:projectId/webhooks", authMiddleware, verifyEmail, authorizeProject('admin'), attachDeveloper, checkWebhookGate, createWebhook); // List all webhooks for a project -router.get("/:projectId/webhooks", authMiddleware, getWebhooks); +router.get("/:projectId/webhooks", authMiddleware, authorizeProject(), getWebhooks); // Get single webhook -router.get("/:projectId/webhooks/:webhookId", authMiddleware, getWebhook); +router.get("/:projectId/webhooks/:webhookId", authMiddleware, authorizeProject(), getWebhook); // Update webhook -router.patch("/:projectId/webhooks/:webhookId", authMiddleware, verifyEmail, attachDeveloper, checkWebhookGate, updateWebhook); +router.patch("/:projectId/webhooks/:webhookId", authMiddleware, verifyEmail, authorizeProject('admin'), attachDeveloper, checkWebhookGate, updateWebhook); // Delete webhook -router.delete("/:projectId/webhooks/:webhookId", authMiddleware, verifyEmail, deleteWebhook); +router.delete("/:projectId/webhooks/:webhookId", authMiddleware, verifyEmail, authorizeProject('admin'), deleteWebhook); // Get delivery history -router.get("/:projectId/webhooks/:webhookId/deliveries", authMiddleware, getDeliveries); +router.get("/:projectId/webhooks/:webhookId/deliveries", authMiddleware, authorizeProject(), getDeliveries); // Test webhook -router.post("/:projectId/webhooks/:webhookId/test", authMiddleware, verifyEmail, attachDeveloper, checkWebhookGate, testWebhook); +router.post("/:projectId/webhooks/:webhookId/test", authMiddleware, verifyEmail, authorizeProject('admin'), attachDeveloper, checkWebhookGate, testWebhook); module.exports = router; diff --git a/apps/web-dashboard/src/App.jsx b/apps/web-dashboard/src/App.jsx index 7649bf07b..b63bf6ff0 100644 --- a/apps/web-dashboard/src/App.jsx +++ b/apps/web-dashboard/src/App.jsx @@ -31,6 +31,7 @@ import RequestPro from './pages/RequestPro'; import AdminProRequests from './pages/AdminProRequests'; import Onboarding from './pages/Onboarding'; import AdminMetrics from './pages/AdminMetrics'; +import TeamMembers from './pages/TeamMembers'; import { LayoutProvider } from './context/LayoutContext'; import { PlanProvider } from './context/PlanContext'; @@ -126,6 +127,8 @@ function AppContent() { } /> + } /> + diff --git a/apps/web-dashboard/src/components/Auth/UserTable.jsx b/apps/web-dashboard/src/components/Auth/UserTable.jsx index 013ae0cd0..da4c1f413 100644 --- a/apps/web-dashboard/src/components/Auth/UserTable.jsx +++ b/apps/web-dashboard/src/components/Auth/UserTable.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { User, Mail, Monitor, Edit2, Key, Trash2 } from 'lucide-react'; -const UserTable = ({ users, onOpenSessions, onEdit, onResetPassword, onDelete }) => { +const UserTable = ({ users, isViewer, onOpenSessions, onEdit, onResetPassword, onDelete }) => { if (users.length === 0) { return (
@@ -44,9 +44,13 @@ const UserTable = ({ users, onOpenSessions, onEdit, onResetPassword, onDelete })
- - - + {!isViewer && ( + <> + + + + + )}
diff --git a/apps/web-dashboard/src/components/CollectionTable.jsx b/apps/web-dashboard/src/components/CollectionTable.jsx index 2b55f7e6e..e00ce66ab 100644 --- a/apps/web-dashboard/src/components/CollectionTable.jsx +++ b/apps/web-dashboard/src/components/CollectionTable.jsx @@ -99,7 +99,7 @@ const DraggableColumnHeader = ({ header, children, style: propStyle, className } ); }; -export default function CollectionTable({ data, activeCollection, onDelete, onView, onEdit, onRecover, recoveringIds }) { +export default function CollectionTable({ data, activeCollection, onDelete, onView, onEdit, onRecover, recoveringIds, isViewer }) { const [now, setNow] = useState(null); useEffect(() => { @@ -231,7 +231,7 @@ export default function CollectionTable({ data, activeCollection, onDelete, onVi > - {activeCollection?.name !== 'users' && ( + {activeCollection?.name !== 'users' && !isViewer && ( (record.isDeleted || recoveringIds.has(record._id)) ? ( - {activeCollection?.name !== 'users' && ( + {activeCollection?.name !== 'users' && !isViewer && ( @@ -115,7 +115,7 @@ const DatabaseHeader = ({ - {activeCollection?.name !== 'users' && ( + {activeCollection?.name !== 'users' && !isViewer && ( diff --git a/apps/web-dashboard/src/components/DatabaseSidebar.jsx b/apps/web-dashboard/src/components/DatabaseSidebar.jsx index fb316b81f..2d7175cc1 100644 --- a/apps/web-dashboard/src/components/DatabaseSidebar.jsx +++ b/apps/web-dashboard/src/components/DatabaseSidebar.jsx @@ -16,7 +16,8 @@ export default function DatabaseSidebar({ navigate, projectId, showUsers = false, - onRequestDelete + onRequestDelete, + isViewer }) { const visibleCollections = collections.filter(c => c.name !== 'users' || showUsers); @@ -35,14 +36,16 @@ export default function DatabaseSidebar({ > - + {!isViewer && ( + + )}
@@ -50,15 +53,17 @@ export default function DatabaseSidebar({ {visibleCollections.length === 0 ? (

No collections yet.

- + {!isViewer && ( + + )}
) : ( visibleCollections.map((c) => ( @@ -73,17 +78,19 @@ export default function DatabaseSidebar({ {c.name}
- + {!isViewer && ( + + )} {activeCollection?._id === c._id && ( )} diff --git a/apps/web-dashboard/src/components/Layout/ProjectNavbar.jsx b/apps/web-dashboard/src/components/Layout/ProjectNavbar.jsx index 2910fa3f9..f1e752f7b 100644 --- a/apps/web-dashboard/src/components/Layout/ProjectNavbar.jsx +++ b/apps/web-dashboard/src/components/Layout/ProjectNavbar.jsx @@ -1,7 +1,7 @@ import { NavLink, useParams, Link } from 'react-router-dom'; import { LayoutDashboard, Database, Shield, HardDrive, Settings, BarChart2, - ArrowLeft, Webhook, Mail + ArrowLeft, Webhook, Mail, Users } from 'lucide-react'; function ProjectNavbar() { @@ -55,6 +55,11 @@ function ProjectNavbar() { Analytics + `nav-link ${isActive ? 'active' : ''}`}> + + Team + + `nav-link ${isActive ? 'active' : ''}`}> Settings diff --git a/apps/web-dashboard/src/components/Layout/Sidebar.jsx b/apps/web-dashboard/src/components/Layout/Sidebar.jsx index b537816a6..264d2028e 100644 --- a/apps/web-dashboard/src/components/Layout/Sidebar.jsx +++ b/apps/web-dashboard/src/components/Layout/Sidebar.jsx @@ -2,7 +2,7 @@ import { Link, useLocation, useParams } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import { LayoutDashboard, Database, Shield, HardDrive, Settings, BarChart2, - ArrowLeft, LogOut, X, Rocket, Webhook + ArrowLeft, LogOut, X, Rocket, Webhook, Users } from 'lucide-react'; import ThemeToggle from '../ThemeToggle'; @@ -55,6 +55,9 @@ function Sidebar({ logo, isOpen, onClose }) { Analytics + + Team + Settings diff --git a/apps/web-dashboard/src/components/RecordList.jsx b/apps/web-dashboard/src/components/RecordList.jsx index a8d4e8849..ee219c0d5 100644 --- a/apps/web-dashboard/src/components/RecordList.jsx +++ b/apps/web-dashboard/src/components/RecordList.jsx @@ -17,7 +17,7 @@ const formatDate = (val) => { }).toLowerCase(); }; -export default function RecordList({ data, activeCollection, onView, onRecover, recoveringIds }) { +export default function RecordList({ data, activeCollection, onView, onRecover, recoveringIds, isViewer }) { const [now, setNow] = useState(null); useEffect(() => { @@ -100,7 +100,7 @@ export default function RecordList({ data, activeCollection, onView, onRecover,
- {(record.isDeleted || recoveringIds.has(record._id)) ? ( + {(!isViewer && (record.isDeleted || recoveringIds.has(record._id))) ? (
; + const myMember = project?.members?.find(m => m.user === user?._id || m.email === user?.email); + const myRole = project?.owner === user?._id ? 'owner' : (myMember?.role || 'viewer'); + const isViewer = myRole === 'viewer'; + return (
navigate(`/project/${projectId}/database?collection=users`)} - onAddUser={() => setIsAddModalOpen(true)} + onAddUser={isViewer ? null : () => setIsAddModalOpen(true)} /> {/* Social Auth Modal */} @@ -327,6 +333,7 @@ export default function Auth() {
{ setResetPasswordUser(u); setNewPassword(''); }} diff --git a/apps/web-dashboard/src/pages/CreateCollection.jsx b/apps/web-dashboard/src/pages/CreateCollection.jsx index 52cd253f4..144468774 100644 --- a/apps/web-dashboard/src/pages/CreateCollection.jsx +++ b/apps/web-dashboard/src/pages/CreateCollection.jsx @@ -475,7 +475,17 @@ function CreateCollection() { } try { const res = await api.get(`/api/projects/${projectId}`); - if (isMounted) setCollections(res.data.collections || []); + if (isMounted) { + const projectData = res.data; + const myMember = projectData.members?.find(m => m.user === user?._id || m.email === user?.email); + const myRole = projectData.owner === user?._id ? 'owner' : (myMember?.role || 'viewer'); + if (myRole === 'viewer') { + toast.error("Viewers cannot create collections"); + navigate(`/project/${projectId}/database`); + return; + } + setCollections(projectData.collections || []); + } } catch (err) { console.error('Failed to fetch collections for Ref picker:', err); if (isMounted) { @@ -488,7 +498,7 @@ function CreateCollection() { }; fetchCollections(); return () => { isMounted = false; }; - }, [projectId]); + }, [projectId, user, navigate]); const addField = () => { setFields([...fields, createEmptyField()]); diff --git a/apps/web-dashboard/src/pages/Dashboard.jsx b/apps/web-dashboard/src/pages/Dashboard.jsx index 3e0e20ddf..9df101d57 100644 --- a/apps/web-dashboard/src/pages/Dashboard.jsx +++ b/apps/web-dashboard/src/pages/Dashboard.jsx @@ -23,6 +23,7 @@ import DocLinks from '../components/Dashboard/DocLinks'; export default function Dashboard() { const [projects, setProjects] = useState([]); const [activity, setActivity] = useState([]); + const [invitations, setInvitations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const { user } = useAuth(); @@ -46,13 +47,15 @@ export default function Dashboard() { useEffect(() => { const fetchData = async () => { try { - const [projectsRes, activityRes] = await Promise.all([ + const [projectsRes, activityRes, invitationsRes] = await Promise.all([ api.get('/api/projects'), - api.get('/api/analytics/activity') + api.get('/api/analytics/activity'), + api.get('/api/invitations').catch(() => ({ data: { success: true, data: [] } })) ]); setProjects(projectsRes.data.success ? projectsRes.data.data : projectsRes.data); setActivity(activityRes.data.success ? activityRes.data.data : activityRes.data); + setInvitations(invitationsRes.data.success ? invitationsRes.data.data : invitationsRes.data || []); // fetchPlanData updates PlanContext which UsageQuota reads from await fetchPlanData(); @@ -116,9 +119,14 @@ export default function Dashboard() { (project.description && project.description.toLowerCase().includes(searchTerm.toLowerCase())) ); - // Calculate global stats directly from projects array for 100% accuracy - const totalDatabaseUsed = projects.reduce((acc, p) => acc + (p.databaseUsed || 0), 0); - const totalStorageUsed = projects.reduce((acc, p) => acc + (p.storageUsed || 0), 0); + const myOwnedProjects = projects.filter(p => { + const ownerId = typeof p.owner === 'object' && p.owner !== null ? p.owner._id || p.owner : p.owner; + return ownerId?.toString() === user?._id?.toString(); + }); + + // Calculate global stats directly from owned projects array for 100% accuracy + const totalDatabaseUsed = myOwnedProjects.reduce((acc, p) => acc + (p.databaseUsed || 0), 0); + const totalStorageUsed = myOwnedProjects.reduce((acc, p) => acc + (p.storageUsed || 0), 0); const formatSize = (bytes) => { if (!bytes) return '0 MB'; @@ -126,11 +134,91 @@ export default function Dashboard() { return `${(bytes / 1024).toFixed(1)} KB`; }; + const handleAcceptInvite = async (inviteId) => { + try { + await api.post(`/api/invitations/${inviteId}/accept`); + toast.success("Invitation accepted!"); + // Refresh projects list & pending invites + const [projectsRes, invitationsRes] = await Promise.all([ + api.get('/api/projects'), + api.get('/api/invitations').catch(() => ({ data: { success: true, data: [] } })) + ]); + setProjects(projectsRes.data.success ? projectsRes.data.data : projectsRes.data); + setInvitations(invitationsRes.data.success ? invitationsRes.data.data : invitationsRes.data || []); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to accept invitation"); + } + }; + + const handleDeclineInvite = async (inviteId) => { + try { + await api.post(`/api/invitations/${inviteId}/decline`); + toast.success("Invitation declined"); + const invitationsRes = await api.get('/api/invitations').catch(() => ({ data: { success: true, data: [] } })); + setInvitations(invitationsRes.data.success ? invitationsRes.data.data : invitationsRes.data || []); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to decline invitation"); + } + }; + return ( + {/* Pending Invitations Banner */} + {!isLoading && invitations.length > 0 && ( +
+ {invitations.map(invite => ( +
+
+

+ 📬 You've been invited to join {invite.project?.name} +

+

+ Role: {invite.role} · Invited by: {invite.invitedBy?.email} +

+
+
+ + +
+
+ ))} +
+ )} + {/* Global Usage Overview Belt - More Compact */} {!isLoading && (
Total Projects - {projects.length} + {myOwnedProjects.length}
diff --git a/apps/web-dashboard/src/pages/Database.jsx b/apps/web-dashboard/src/pages/Database.jsx index adb598585..10e699e48 100644 --- a/apps/web-dashboard/src/pages/Database.jsx +++ b/apps/web-dashboard/src/pages/Database.jsx @@ -36,6 +36,10 @@ export default function Database() { const [selectedRecord, setSelectedRecord] = useState(null); const [editingRecord, setEditingRecord] = useState(null); + const myMember = project?.members?.find(m => m.user === user?._id || m.email === user?.email); + const myRole = project?.owner === user?._id ? 'owner' : (myMember?.role || 'viewer'); + const isViewer = myRole === 'viewer'; + const [queryParams, setQueryParams] = useState({ page: parseInt(searchParams.get('page')) || 1, limit: parseInt(searchParams.get('limit')) || 50, @@ -246,7 +250,6 @@ export default function Database() { c.name !== 'users')} activeCollection={activeCollection} setActiveCollection={setActiveCollection} @@ -254,6 +257,7 @@ export default function Database() { navigate={navigate} projectId={projectId} onRequestDelete={setCollectionToDelete} + isViewer={isViewer} />
@@ -283,6 +287,7 @@ export default function Database() { showDeleted={showDeleted} setShowDeleted={setShowDeleted} onFiltersGenerated={handleFiltersGenerated} + isViewer={isViewer} />
@@ -342,7 +347,9 @@ export default function Database() { )}
- + {!isViewer && ( + + )}
@@ -353,6 +360,7 @@ export default function Database() { onView={setSelectedRecord} onRecover={handleRecoverRecord} recoveringIds={recoveringIds} + isViewer={isViewer} /> ) : viewMode === "table" ? ( { if (activeCollection?.name === 'users') return; setEditingRecord(rec); setIsAddModalOpen(true); }} onRecover={handleRecoverRecord} recoveringIds={recoveringIds} + isViewer={isViewer} /> ) : (
@@ -385,11 +394,13 @@ export default function Database() {

No collections found

- Collections (or Tables) are where your project's data is stored. Create your first collection to start saving data. + Collections (or Tables) are where your project's data is stored. {isViewer ? 'The project owner has not created any collections yet.' : 'Create your first collection to start saving data.'}

- + {!isViewer && ( + + )}
)} @@ -399,7 +410,7 @@ export default function Database() { onClose={() => setSelectedRecord(null)} record={selectedRecord} fields={activeCollection?.model || []} - onEdit={(activeCollection?.name === 'users' || selectedRecord?.isDeleted) ? null : (rec) => { setEditingRecord(rec); setIsAddModalOpen(true); }} + onEdit={(activeCollection?.name === 'users' || selectedRecord?.isDeleted || isViewer) ? null : (rec) => { setEditingRecord(rec); setIsAddModalOpen(true); }} /> {isAddModalOpen && ( diff --git a/apps/web-dashboard/src/pages/MailPlatform.jsx b/apps/web-dashboard/src/pages/MailPlatform.jsx index 30c87768b..bdd930d3a 100644 --- a/apps/web-dashboard/src/pages/MailPlatform.jsx +++ b/apps/web-dashboard/src/pages/MailPlatform.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import api from '../utils/api'; import toast from 'react-hot-toast'; +import { useAuth } from '../context/AuthContext'; import { Mail, Send, Eye, RefreshCw, Plus, Trash2, Users, UserPlus, Radio, ShieldAlert, AlertCircle @@ -24,6 +25,8 @@ export default function MailPlatform() { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const { user } = useAuth(); + // Project context const [project, setProject] = useState(null); @@ -234,6 +237,9 @@ export default function MailPlatform() { ); const isByok = !!project?.hasResendApiKey; + + const myMember = project?.members?.find(m => m.user === user?._id || m.email === user?.email); + const isViewer = project?.owner !== user?._id && (myMember?.role === 'viewer'); return (
@@ -267,13 +273,15 @@ export default function MailPlatform() {
- + {!isViewer && ( + + )}
- + {!isViewer && ( + + )}
)) : (

No remote audiences found

@@ -430,28 +440,30 @@ export default function MailPlatform() { {/* Create new audience form */} -
- -
- setNewAudienceName(e.target.value)} - style={{ flex: 1, padding: '6px 10px', fontSize: '0.75rem', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff' }} - /> - -
-
+ {!isViewer && ( +
+ +
+ setNewAudienceName(e.target.value)} + style={{ flex: 1, padding: '6px 10px', fontSize: '0.75rem', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff' }} + /> + +
+
+ )} {/* Right pane: Active Contacts inside Audience */} @@ -469,38 +481,40 @@ export default function MailPlatform() { {/* Add Contact Quick Form */} -
- setNewContactEmail(e.target.value)} - style={{ padding: '6px 10px', fontSize: '0.75rem', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff' }} - /> - setNewContactFirstName(e.target.value)} - style={{ padding: '6px 10px', fontSize: '0.75rem', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff' }} - /> - setNewContactLastName(e.target.value)} - style={{ padding: '6px 10px', fontSize: '0.75rem', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff' }} - /> - -
+ {!isViewer && ( +
+ setNewContactEmail(e.target.value)} + style={{ padding: '6px 10px', fontSize: '0.75rem', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff' }} + /> + setNewContactFirstName(e.target.value)} + style={{ padding: '6px 10px', fontSize: '0.75rem', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff' }} + /> + setNewContactLastName(e.target.value)} + style={{ padding: '6px 10px', fontSize: '0.75rem', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff' }} + /> + +
+ )} {/* Contacts Table */}
@@ -520,13 +534,15 @@ export default function MailPlatform() { {c.first_name || c.last_name ? `${c.first_name || ''} ${c.last_name || ''}` : '—'} {new Date(c.created_at).toLocaleDateString()} - + {!isViewer && ( + + )} )) : ( @@ -568,58 +584,64 @@ export default function MailPlatform() {
-
-
- - -
+ {!isViewer ? ( + +
+ + +
-
- - setBroadcastSubject(e.target.value)} - style={{ width: '100%', padding: '8px 12px', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff', fontSize: '0.8rem' }} - /> -
+
+ + setBroadcastSubject(e.target.value)} + style={{ width: '100%', padding: '8px 12px', background: 'var(--color-bg-input)', border: '1px solid var(--color-border)', borderRadius: '4px', color: '#fff', fontSize: '0.8rem' }} + /> +
-
- -