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() {
-
+ {sbLogo && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
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));
}
}