From 6b6f5e33f5ccf7010c7edb4e7aced8cfce938bb1 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Mar 2026 16:37:45 +0100 Subject: [PATCH] fix: admin role change, logo access, magic link validation, login help - Add updateTeamMemberRole mutation for admins to change team member roles - Allow any team member (not just lead) to change project logo - Add visible "Add logo"/"Change" label under logo for discoverability - Pre-check email existence before sending magic link (show error) - Add "forgot which email" contact link on login page Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/projects/[id]/page.tsx | 32 ++++++++++-- src/app/(applicant)/applicant/page.tsx | 37 ++++++-------- src/app/(applicant)/applicant/team/page.tsx | 39 +++++++-------- src/app/(auth)/login/page.tsx | 19 +++++++ src/app/api/auth/check-email/route.ts | 26 ++++++++++ src/server/routers/project.ts | 52 ++++++++++++++++++++ 6 files changed, 159 insertions(+), 46 deletions(-) create mode 100644 src/app/api/auth/check-email/route.ts diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 0e25afd..e376cd6 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -171,6 +171,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { }, }) + const updateTeamMemberRole = trpc.project.updateTeamMemberRole.useMutation({ + onSuccess: () => { + toast.success('Role updated') + utils.project.getFullDetail.invalidate({ id: projectId }) + }, + onError: (err) => { + toast.error(err.message || 'Failed to update role') + }, + }) + const removeTeamMember = trpc.project.removeTeamMember.useMutation({ onSuccess: () => { toast.success('Team member removed') @@ -538,9 +548,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { {member.user.name || 'Unnamed'} - - {member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} - +

{member.user.email} diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 1f55990..b585089 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -124,17 +124,17 @@ export default function ApplicantDashboardPage() { {/* Header */}

- {/* Project logo — clickable for team leads to change */} - {project.isTeamLead ? ( - utils.applicant.getMyDashboard.invalidate()} + {/* Project logo — clickable for any team member to change */} + utils.applicant.getMyDashboard.invalidate()} + > + - - ) : ( -
- {data.logoUrl ? ( - {project.title} - ) : ( - - )} -
- )} +
+ + {data.logoUrl ? 'Change' : 'Add logo'} + + +

{project.title}

diff --git a/src/app/(applicant)/applicant/team/page.tsx b/src/app/(applicant)/applicant/team/page.tsx index 3c99903..5082044 100644 --- a/src/app/(applicant)/applicant/team/page.tsx +++ b/src/app/(applicant)/applicant/team/page.tsx @@ -244,17 +244,17 @@ export default function ApplicantProjectPage() {
{/* Header */}
- {/* Project logo — clickable for team leads */} - {isTeamLead ? ( - refetchLogo()} + {/* Project logo — clickable for any team member to change */} + refetchLogo()} + > + - - ) : ( -
- {logoUrl ? ( - {project.title} - ) : ( - - )} -
- )} +
+ + {logoUrl ? 'Change' : 'Add logo'} + + +

{project.title} @@ -388,7 +383,7 @@ export default function ApplicantProjectPage() { {/* Project Logo */} - {isTeamLead && projectId && ( + {projectId && ( diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 4434c8c..34f1626 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -69,6 +69,19 @@ export default function LoginPage() { setError(null) try { + // Pre-check: does this email exist? + const checkRes = await fetch('/api/auth/check-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + const checkData = await checkRes.json() + if (!checkData.exists) { + setError('No account found with this email address. Please check the email you used to sign up, or contact the administrator.') + setIsLoading(false) + return + } + // Get CSRF token first const csrfRes = await fetch('/api/auth/csrf') const { csrfToken } = await csrfRes.json() @@ -300,6 +313,12 @@ export default function LoginPage() { )} +

+ Don't remember which email you used?{' '} + + Contact the MOPC team + +

diff --git a/src/app/api/auth/check-email/route.ts b/src/app/api/auth/check-email/route.ts new file mode 100644 index 0000000..051c386 --- /dev/null +++ b/src/app/api/auth/check-email/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +/** + * Pre-check whether an email exists before sending a magic link. + * This is a closed platform (no self-registration) so revealing + * email existence is acceptable and helps users who mistype. + */ +export async function POST(req: NextRequest) { + try { + const { email } = await req.json() + if (!email || typeof email !== 'string') { + return NextResponse.json({ exists: false }, { status: 400 }) + } + + const user = await prisma.user.findUnique({ + where: { email: email.toLowerCase().trim() }, + select: { status: true }, + }) + + const exists = !!user && user.status !== 'SUSPENDED' + return NextResponse.json({ exists }) + } catch { + return NextResponse.json({ exists: false }, { status: 500 }) + } +} diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index c3edea3..fd7d0b9 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -1611,6 +1611,58 @@ export const projectRouter = router({ return { success: true } }), + /** + * Update a team member's role (admin only). + * Prevents removing the last LEAD. + */ + updateTeamMemberRole: adminProcedure + .input( + z.object({ + projectId: z.string(), + userId: z.string(), + role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']), + }) + ) + .mutation(async ({ ctx, input }) => { + const { projectId, userId, role } = input + + const member = await ctx.prisma.teamMember.findUniqueOrThrow({ + where: { projectId_userId: { projectId, userId } }, + select: { role: true }, + }) + + // Prevent removing the last LEAD + if (member.role === 'LEAD' && role !== 'LEAD') { + const leadCount = await ctx.prisma.teamMember.count({ + where: { projectId, role: 'LEAD' }, + }) + if (leadCount <= 1) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Cannot change the role of the last team lead', + }) + } + } + + await ctx.prisma.teamMember.update({ + where: { projectId_userId: { projectId, userId } }, + data: { role }, + }) + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'UPDATE_TEAM_MEMBER_ROLE', + entityType: 'Project', + entityId: projectId, + detailsJson: { targetUserId: userId, oldRole: member.role, newRole: role }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + + return { success: true } + }), + // ========================================================================= // BULK NOTIFICATION ENDPOINTS // =========================================================================