diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3070d9e..bb3bd5a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,7 +24,8 @@ "Bash(git config:*)", "Bash(git remote add:*)", "Bash(git push:*)", - "Bash(git remote set-url:*)" + "Bash(git remote set-url:*)", + "Bash(cd /mnt/d/spliceboti/OneDrive/CodingProjects/superbowl/public/images && ls | grep -v -E '^\\(nfc-generic\\\\.png|afc-generic\\\\.png\\)$' | xargs rm)" ] } } diff --git a/Dockerfile b/Dockerfile index fd25a4f..7e390d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,8 @@ ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs -# Install prisma CLI for db push at runtime +# Install prisma CLI and psql client for runtime migrations +RUN apk add --no-cache postgresql-client RUN npm install -g prisma@6 COPY --from=builder /app/public ./public diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ac1db63..2efc1c2 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,6 +3,12 @@ set -e mkdir -p /app/public/uploads +echo "Running pre-migration fixes..." +# Rename VIEWER enum value to COMMISSIONER if it still exists (one-time migration) +# Strip Prisma-specific query params (e.g. ?schema=public) before passing to psql +PSQL_URL=$(echo "$DATABASE_URL" | sed 's/?.*//') +psql "$PSQL_URL" -c "ALTER TYPE \"Role\" RENAME VALUE 'VIEWER' TO 'COMMISSIONER';" 2>/dev/null || true + echo "Running Prisma migrations..." npx prisma db push --skip-generate diff --git a/legacy/Screenshot 2026-02-11 142658.png b/legacy/Screenshot 2026-02-11 142658.png new file mode 100644 index 0000000..c1fd886 Binary files /dev/null and b/legacy/Screenshot 2026-02-11 142658.png differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 565470d..a392e5a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,7 +9,7 @@ datasource db { enum Role { ADMIN - VIEWER + COMMISSIONER PLAYER } diff --git a/prisma/seed.ts b/prisma/seed.ts index 0d856bd..53b6ba1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -95,6 +95,11 @@ async function main() { subject: 'Final Results - {{eventName}}', body: 'Hi {{name}},\n\nThe game is over! Here are the final results for {{eventName}}:\n\nWinners:\n{{winners}}\n\nCongratulations to all the winners!\n\nThank you for participating in this year\'s Super Bowl Squares. We hope you had a great time!\n\nView the final board at: {{gameUrl}}\n\n{{commissioner}}', }, + { + name: 'password_change', + subject: 'Password Changed - {{eventName}}', + body: 'Hi {{name}},\n\nYour password for {{eventName}} Squares has been changed successfully.\n\nIf you did not make this change, please contact the commissioner immediately.\n\nYou can log in at: {{gameUrl}}/login\n\n{{commissioner}}', + }, ]; for (const template of templates) { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 45977b9..910e974 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -28,7 +28,7 @@ export default function AdminLayout({ // Viewer can't access certain pages const viewerRestricted = ['/admin/settings', '/admin/users', '/admin/backup']; - const filteredNav = role === 'VIEWER' + const filteredNav = role === 'COMMISSIONER' ? navItems.filter((item) => !viewerRestricted.includes(item.href)) : navItems; @@ -41,7 +41,7 @@ export default function AdminLayout({ SB Squares

- {role === 'VIEWER' ? 'Commissioner' : 'Admin Panel'} + {role === 'COMMISSIONER' ? 'Commissioner' : 'Admin Panel'}

-
- +
+ + Change Password + + ← Back to Grid
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 01df4c7..9a94997 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -113,7 +113,7 @@ export default function UsersPage() { className="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs" > - + @@ -153,7 +153,7 @@ export default function UsersPage() { className="input-field" > - +
diff --git a/src/app/api/users/me/route.ts b/src/app/api/users/me/route.ts new file mode 100644 index 0000000..f930501 --- /dev/null +++ b/src/app/api/users/me/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { hash, compare } from 'bcryptjs'; +import { prisma } from '@/lib/prisma'; +import { authOptions } from '@/lib/auth'; +import { sendPasswordChangedEmail } from '@/lib/autoEmail'; + +// PATCH /api/users/me — change own password +export async function PATCH(request: Request) { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const userId = (session.user as any).id; + const body = await request.json(); + const { currentPassword, newPassword } = body; + + if (!currentPassword || !newPassword) { + return NextResponse.json( + { error: 'Current password and new password are required' }, + { status: 400 } + ); + } + + if (newPassword.length < 8) { + return NextResponse.json( + { error: 'New password must be at least 8 characters' }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const valid = await compare(currentPassword, user.passwordHash); + if (!valid) { + return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 }); + } + + const passwordHash = await hash(newPassword, 12); + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }); + + sendPasswordChangedEmail(user.name, user.email); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9846222..ef1dbe2 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { signIn } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import Link from 'next/link'; -import Image from 'next/image'; export default function LoginPage() { const router = useRouter(); @@ -14,6 +13,14 @@ export default function LoginPage() { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [sbLogo, setSbLogo] = useState(''); + + useEffect(() => { + fetch('/api/settings') + .then((res) => res.json()) + .then((data) => { if (data.sbLogo) setSbLogo(data.sbLogo); }) + .catch(() => {}); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -40,13 +47,14 @@ export default function LoginPage() {
- Super Bowl + {sbLogo && ( + // eslint-disable-next-line @next/next/no-img-element + Super Bowl + )}

Sign In

diff --git a/src/app/my-squares/page.tsx b/src/app/my-squares/page.tsx index b228da3..f45109e 100644 --- a/src/app/my-squares/page.tsx +++ b/src/app/my-squares/page.tsx @@ -3,6 +3,9 @@ import { useSession } from 'next-auth/react'; import { useEffect, useState } from 'react'; import Link from 'next/link'; +import { Modal } from '@/components/ui/Modal'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; interface Square { position: string; @@ -19,6 +22,15 @@ export default function MySquaresPage() { const [squares, setSquares] = useState([]); const [loading, setLoading] = useState(true); + // Password change modal state + const [pwModal, setPwModal] = useState(false); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [pwError, setPwError] = useState(''); + const [pwSuccess, setPwSuccess] = useState(''); + const [pwLoading, setPwLoading] = useState(false); + useEffect(() => { fetch('/api/squares') .then((res) => res.json()) @@ -32,6 +44,43 @@ export default function MySquaresPage() { .finally(() => setLoading(false)); }, [session]); + const handleChangePassword = async () => { + setPwError(''); + setPwSuccess(''); + + if (newPassword !== confirmPassword) { + setPwError('New passwords do not match'); + return; + } + + if (newPassword.length < 8) { + setPwError('New password must be at least 8 characters'); + return; + } + + setPwLoading(true); + try { + const res = await fetch('/api/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + const data = await res.json(); + if (!res.ok) { + setPwError(data.error || 'Failed to change password'); + } else { + setPwSuccess('Password changed successfully. A confirmation email has been sent.'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } + } catch { + setPwError('Failed to change password'); + } finally { + setPwLoading(false); + } + }; + if (loading) { return (
@@ -45,9 +94,14 @@ export default function MySquaresPage() {

My Squares

- - Back to Grid - +
+ + + Back to Grid + +
{squares.length === 0 ? ( @@ -94,6 +148,51 @@ export default function MySquaresPage() {
)}
+ + setPwModal(false)} title="Change Password"> +
+ {pwError && ( +
+ {pwError} +
+ )} + {pwSuccess && ( +
+ {pwSuccess} +
+ )} + setCurrentPassword(e.target.value)} + /> + setNewPassword(e.target.value)} + placeholder="Min 8 characters" + /> + setConfirmPassword(e.target.value)} + /> +
+ + +
+
+
); } diff --git a/src/components/grid/GridHeader.tsx b/src/components/grid/GridHeader.tsx index 93b3da1..0357235 100644 --- a/src/components/grid/GridHeader.tsx +++ b/src/components/grid/GridHeader.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { useSession } from 'next-auth/react'; +import { useSession, signOut } from 'next-auth/react'; import Image from 'next/image'; import Link from 'next/link'; import { Modal } from '@/components/ui/Modal'; @@ -74,9 +74,9 @@ export function GridHeader({ settings }: GridHeaderProps) { Admin )} - + ) : ( <> diff --git a/src/lib/autoEmail.ts b/src/lib/autoEmail.ts index 74ef9e9..6ca0fd9 100644 --- a/src/lib/autoEmail.ts +++ b/src/lib/autoEmail.ts @@ -276,6 +276,31 @@ export function sendGameResultsEmails(winners: Record { + const template = await getTemplate('password_change'); + if (!template) return; + + const { settings, gameUrl } = await getGameContext(); + + const variables: Record = { + name: userName, + email: userEmail, + commissioner: settings?.commissioner || '', + eventName: settings?.eventName || '', + gameUrl, + }; + + const subject = renderTemplate(template.subject, variables); + const body = renderTemplate(template.body, variables); + await sendEmail(userEmail, subject, body); + console.log(`[AutoEmail] Password changed email sent to ${userEmail}`); + }); +} + /** * Check for squares approaching grace period deadline and send reminders. * Called periodically from server.js. diff --git a/src/middleware.ts b/src/middleware.ts index b410613..d4d4cda 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -8,7 +8,7 @@ export default withAuth( // Admin routes require ADMIN or VIEWER role if (pathname.startsWith('/admin')) { - if (token?.role !== 'ADMIN' && token?.role !== 'VIEWER') { + if (token?.role !== 'ADMIN' && token?.role !== 'COMMISSIONER') { return NextResponse.redirect(new URL('/login', req.url)); } }