A club website for Melbourne University Ultimate: roster and tournament selection, events, announcements, alumni and video content, behind an admin dashboard. Built with Next.js 15 and MongoDB.
- Built: July 2025. Revisited August 2026 for a security and correctness pass β authorisation, build reliability, dependency advisories. Between those two dates nothing happened, and nothing is scheduled after them: treat it as a finished project that was picked back up once, not a live one.
- Source is public, but there is no licence. See Licence below. This is not an open-source project in the OSI sense.
- Single-club application. Club name, branding and copy are hardcoded in a handful of files; it is not a configurable multi-club template.
https://melb-uni-ultimate.vercel.app
The application needs a MongoDB connection to serve any data-driven page. If the database is unavailable or paused, pages will render with empty data regions.
| Home Page | Admin Dashboard | Events |
|---|---|---|
![]() |
![]() |
![]() |
- Home: club intro, quick links and highlights
- About: club history, values and leadership (static content)
- Announcements: published club news, with a detail page per item
- Events: practice, tournament and social events, with status derived from start/end dates (upcoming / ongoing / completed)
- Videos: YouTube embeds, filtered to published and publicly visible items
- Roster: team roster with gender and position badges, search and filters
- Alumni: alumni directory; contact and employment fields are withheld from non-admin callers server-side, not merely hidden in the UI
- Contact: club contact details and social links (no submission form)
- Profile: authenticated member profile
- Admin dashboard (
/dashboard): a single tabbed surface for announcements, events, videos, players, alumni and tournaments - Tournament rosters: players are attached to tournaments through a normalised join collection with a compound unique index, so the same player cannot be selected twice for the same tournament and team
- Video management: add and edit YouTube videos, with URL/ID parsing, tag filtering, a publish flag and visibility filtering by audience
- CRUD: create, edit and delete with confirmation dialogs and toasts
- Authentication: NextAuth credentials provider, bcrypt password hashing,
JWT sessions, and two roles β
userandadmin
- Next.js 15.5.23 (App Router, route handlers)
- React 19.1.0
- TypeScript 5.8.3 β
strictmode enabled - Tailwind CSS 4 + shadcn/ui (nine generated primitives, plus hand-written shared components)
- MongoDB + Mongoose 8.24.2 (nine schemas with validators and indexes)
- NextAuth 4.24.11 (credentials provider, JWT strategy, no adapter β see below)
- Lucide React and react-icons (iconography)
- ESLint (
next/core-web-vitals,next/typescript) - Vitest β a narrow suite covering authorisation and the write helpers, run in CI alongside typecheck, lint and a build. See Tests; it is deliberately not broad coverage.
git clone https://github.com/liuyuelintop/melb-uni-ultimate.git
cd melb-uni-ultimate
npm install
cp docs/env.template .env.local # then fill in your own values
npm run dev- Visit http://localhost:3000
- Sign up as a new user at
/signup. New accounts get theuserrole.
There is deliberately no HTTP route that grants admin β an endpoint that did was the project's original privilege-escalation defect. Use the local CLI, which requires database credentials and a shell and so adds no remotely reachable privilege path:
node scripts/admin.js list # who exists, and their role
node scripts/admin.js set-role <email> admin # promote (or demote with: user)
node scripts/admin.js reset-password <email> # prompts, no echoIt reads MONGODB_URI from .env.local and needs no extra dependencies. After a
role change, sign out and back in β the server reads the role from the database
on every request, but the client's cached session still shows the old value.
Required environment variables are listed in
docs/env.template: MONGODB_URI, NEXTAUTH_SECRET and
NEXTAUTH_URL. Never commit their values.
npm test # once
npm run test:watch
npm run typecheckBe clear about the scope: this is not broad coverage. It is two things worth locking down, and nothing else. There are no component tests, no end-to-end tests, and nothing that exercises a query against a real database.
1. Every mutating handler refuses an anonymous caller.
tests/auth-guards.test.ts finds the route files on disk, extracts each exported
POST/PUT/PATCH/DELETE, calls it with no session and asserts a 401 or 403.
Discovery is by filesystem scan rather than a hand-written list, so a new route
is picked up the moment it is added and has to either pass or be added to an
explicit allowlist. That allowlist holds exactly one entry, POST /api/signup,
which has to be reachable or nobody could create an account.
Two details make the test mean what it says. getServerSession is mocked to
return no session, so an anonymous caller is genuinely simulated rather than
approximated; the test then asserts it was called, which distinguishes
"refused because anonymous" from "refused by accident". And dbConnect is mocked
to throw, so a handler that reaches the database before authorising fails
with a clear message rather than hanging for the driver's 30-second timeout.
2. writes.ts behaves as documented. tests/writes.test.ts covers
optional() β including that it keeps a legitimate 0 or false while dropping
blanks, which a naive if (!value) would get wrong β and the duplicate-key and
connection-error branches, against error objects shaped like the driver's real
ones.
CI runs typecheck, lint, tests and a build on every push and pull request
(.github/workflows/ci.yml). The build step runs
with no MONGODB_URI on purpose: needing a database credential to build is a
regression this project has already had twice, and that step is what catches it
before a deploy does.
melb-uni-ultimate/
βββ src/
β βββ app/ # Next.js App Router
β β βββ (admin)/ # Admin pages (dashboard)
β β β βββ layout.tsx # Server-side auth + admin role gate
β β βββ (auth)/ # Authentication pages (login, signup, unauthorized)
β β βββ (protected)/ # Protected member pages (profile)
β β βββ (public)/ # Public pages (about, announcements, events, videos, etc.)
β β βββ api/ # Route handlers
β β β βββ (admin)/ # Dashboard stats
β β β βββ (auth)/ # refresh, signup
β β β βββ (protected)/ # players, tournaments, user, videos
β β β βββ (public)/ # alumni, announcements, events, roster
β β β βββ auth/ # NextAuth.js routes
β β βββ layout.tsx # Root layout
β β βββ page.tsx # Home page
β β βββ providers.tsx # App providers
β βββ features/ # Feature-based components and logic
β β βββ about/ admin/ alumni/ announcements/
β β βββ events/ roster/ tournaments/ videos/
β βββ shared/ # Shared across features
β β βββ components/ # UI primitives, layout, home
β β βββ context/ # React contexts (notifications)
β β βββ data/ # Static page content
β β βββ hooks/ # useApi, useCrud and resource hooks
β β βββ lib/auth/ # NextAuth options + authorisation guards
β β βββ lib/db/ # Mongoose connection and models
β β βββ types/ # Shared TypeScript types
β βββ styles/ # Global styles
β βββ middleware.ts # Next.js middleware
βββ public/ # Static assets
βββ scripts/ # Local CLI (admin.js) - not part of the build
βββ tests/ # Vitest: authorisation and write-helper tests
βββ docs/ # Deployment guides and env templates
βββ .github/workflows/ci.yml # Typecheck, lint, test, build
βββ package.json
- Feature-based organisation: each feature area owns its components under
src/features/<feature>/. - Route groups: Next.js route groups
()organise files by intended access level. They are naming only β parentheses do not appear in the URL and confer no protection. Authorisation is enforced in code, not by directory layout. This has bitten this codebase three times, so be concrete about it:src/app/(admin)/dashboard/page.tsxβ/dashboard, not/adminsrc/app/api/(auth)/signup/route.tsβ/api/signup, not/api/auth/signupβ that path belongs to the NextAuth catch-all, which answers400 text/plainfor any action it does not recognisesrc/app/api/(public)/...is not public, and(protected)/...is not protected, by virtue of the folder name alone
- Shared layer: common UI primitives, hooks and types live in
src/shared/. - Authorisation:
src/shared/lib/auth/guards.tsis the single place that reads a session. Route handlers callrequireAdmin()/requireAuth()and return the guard's response on failure, so a mutating endpoint cannot be written without an explicit decision about who may call it. See the "Authentication and authorisation" section below. - Data fetching: a generic
useApi<T>hook owns fetch/loading/error state;useCrud<T>composes it and adds create/update/delete with optimistic local list updates. The thirteen resource hooks insrc/shared/hooks/are built on that pair. - Database connection:
src/shared/lib/db/mongoose.tscaches the connection promise onglobaland invalidates it on failure, so serverless invocations reuse one connection instead of exhausting the pool. It is the only connection module; an earlier second one was removed, because two modules meant two pools and two places to keep the validation rules in step. MONGODB_URIis validated insidedbConnect(), never at module scope. Module-scope validation makes a database credential a build requirement:next buildimports every route module to collect page data, so the check runs with no environment and fails the build. The validation rejects a missing value, a wrong scheme (naming the scheme it found, never the credentials), and a URI with an empty database path β that last one is silent otherwise, as the driver quietly falls back to a database literally calledtest, which once cost this project an afternoon.- Every DB- or session-touching route handler declares
export const dynamic = "force-dynamic". Without it Next.js treats a handler with no request-specific input as static and executes it at build time, which fails the build withFailed to collect page data. All eighteen have it; keep it on any new one. - Writes go through
src/shared/lib/db/writes.ts:optional()omits blank fields rather than storingnull(storingnullcollides under a unique index, so several documents with "no student ID" conflict with each other), andduplicateKey()/isConnectionError()/writeFailureResponse()turn a driver error into a 409 or a 503 that names the index instead of an opaque 500. - No NextAuth adapter, deliberately. An adapter persists sessions, links
OAuth accounts and stores email verification tokens; this app does none of
those β the session strategy is explicitly
jwt, the only provider is credentials, andauthorizereads the user through Mongoose itself. The adapter that used to sit there also opened a database connection at import, so merely importing the auth options connected to MongoDB.session.user.idstill resolves, fromtoken.sub.
Two roles exist in the data model: user and admin
(src/shared/lib/db/models/user.ts).
- Public: anyone can read public pages and the public read endpoints.
user: any account created through signup. Can sign in and view protected member pages.admin: can reach/dashboardand the admin management surfaces.
How authorisation works. Every authorisation decision goes through
src/shared/lib/auth/guards.ts, which is the only module in the codebase that
calls getServerSession:
| Helper | Use |
|---|---|
getViewer() |
Resolve the caller, or null. Returns role read from the database. |
viewerIsAdmin() |
For read endpoints that vary their output by role. |
requireAuth() |
Require any signed-in caller. |
requireAdmin() |
Require an admin. |
requireAuth and requireAdmin return a discriminated union, so a route reads:
const auth = await requireAdmin();
if (!auth.ok) return auth.response; // 401 or 403
// auth.viewer is typed as a Viewer from here onTwo deliberate properties:
authOptionscannot be forgotten.getServerSession()populatessession.user.roleonly when passedauthOptions. Called without them it still returns a valid session, but withroleundefined β so a role check silently rejects everyone, including real admins, and TypeScript cannot catch it because the parameter is optional. Having exactly one call site is the only reliable fix.- Roles come from the database, not the JWT claim. The claim is written at sign-in and then fixed for the life of the token, so revoking an admin would not take effect until it expired. Reading the current value costs one indexed lookup and applies to the caller's next request. Anonymous callers skip the query entirely.
How /dashboard is protected. src/app/(admin)/layout.tsx is a server
component that calls getViewer() and redirects unauthenticated visitors to
/login and non-admins to /unauthorized. This is the authoritative gate.
src/middleware.ts additionally checks the JWT for /dashboard as defence in
depth.
What each role can write. Announcements, events, players, alumni, roster
entries and tournaments are admin-only for every mutating method. Members can
edit their own profile, and can add videos β a member's video is visible to
members only until an admin widens its audience, and members can edit or delete
only videos they created. /api/signup is intentionally open; new accounts get
the user role and there is no self-service route to admin.
- Push to GitHub and connect the repository to Vercel
- Set the environment variables from
docs/env.template - Deploy
npm run build
npm startThis repository has no licence, which means external contributions cannot be accepted as things stand β a contributor has no grant to build on, and the project has no terms to accept contributions under. If you want to collaborate, open an issue first so licensing can be sorted out.
If you are working on a fork for your own reference:
- Create a feature branch (
git checkout -b feature/your-feature) - Keep commits scoped and descriptive
- Run
npm run typecheck,npm testandnpm run buildbefore pushing. CI runs the same four checks, but it is quicker to find out locally
- Club enquiries: see the contact page
- Bugs and questions about the code: GitHub Issues
Q: Can I use this for my own club? A: Not as-is. There is no licence granting you the right to use, modify or redistribute the code, and the club's name and content are hardcoded throughout. Open an issue if you would like to discuss it.
Q: Is it free? A: The source is publicly readable, but it is not licensed for reuse. "Public" and "open source" are not the same thing.
Q: How do I get admin access?
A: node scripts/admin.js set-role <your-email> admin. There is no HTTP route
that grants admin, by design.
Q: I forgot the admin password.
A: node scripts/admin.js reset-password <email>. Use list first if you are not
sure which account is the admin.
Q: Are there tests? A: A narrow suite, not broad coverage β see Tests. It asserts that every mutating endpoint refuses an anonymous caller, and that the shared write helpers behave as documented. There are no component or end-to-end tests, and nothing runs against a real database. CI runs typecheck, lint, tests and a build on every push and pull request.
None. No licence file has been added to this repository, so default copyright applies and all rights are reserved. The source is readable because the repository is public; that is not a grant of permission to use, copy, modify or redistribute it.
Go Ultimate! π₯


