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'}
-
+
+ updateTeamMemberRole.mutate({
+ projectId: project.id,
+ userId: member.user.id,
+ role: value as 'LEAD' | 'MEMBER' | 'ADVISOR',
+ })
+ }
+ >
+
+
+
+
+ Lead
+ Member
+ Advisor
+
+
{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 ? (
) : (
@@ -143,17 +143,12 @@ export default function ApplicantDashboardPage() {
-
-
- ) : (
-
- {data.logoUrl ? (
-
- ) : (
-
- )}
-
- )}
+
+
+ {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 ? (
) : (
@@ -263,17 +263,12 @@ export default function ApplicantProjectPage() {
-
-
- ) : (
-
- {logoUrl ? (
-
- ) : (
-
- )}
-
- )}
+
+
+ {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
// =========================================================================