Skip to content

Repository files navigation

πŸ₯ Melbourne University Ultimate Frisbee Club

Next.js React TypeScript Tailwind CSS Mongoose Vercel CI

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.


πŸ“Œ Project status

  • 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.

🌍 Deployment

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.


🎬 Screenshots

Home Page Admin Dashboard Events
Home Admin Events

πŸš€ Features

🏠 Public pages

  • 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)

πŸ”’ Member and admin features

  • 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 β€” user and admin

πŸ› οΈ Tech stack

  • Next.js 15.5.23 (App Router, route handlers)
  • React 19.1.0
  • TypeScript 5.8.3 β€” strict mode 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.

⚑ Quick start

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

Account maintenance

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 echo

It 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.


πŸ§ͺ Tests

npm test          # once
npm run test:watch
npm run typecheck

Be 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.


πŸ—‚οΈ Project structure

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

πŸ—οΈ Architecture notes

  • 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 /admin
    • src/app/api/(auth)/signup/route.ts β†’ /api/signup, not /api/auth/signup β€” that path belongs to the NextAuth catch-all, which answers 400 text/plain for any action it does not recognise
    • src/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.ts is the single place that reads a session. Route handlers call requireAdmin() / 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 in src/shared/hooks/ are built on that pair.
  • Database connection: src/shared/lib/db/mongoose.ts caches the connection promise on global and 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_URI is validated inside dbConnect(), never at module scope. Module-scope validation makes a database credential a build requirement: next build imports 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 called test, 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 with Failed 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 storing null (storing null collides under a unique index, so several documents with "no student ID" conflict with each other), and duplicateKey() / 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, and authorize reads 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.id still resolves, from token.sub.

πŸ›‘οΈ Authentication and authorisation

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 /dashboard and 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 on

Two deliberate properties:

  • authOptions cannot be forgotten. getServerSession() populates session.user.role only when passed authOptions. Called without them it still returns a valid session, but with role undefined β€” 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.


πŸ—οΈ Deployment

Vercel

  • Push to GitHub and connect the repository to Vercel
  • Set the environment variables from docs/env.template
  • Deploy

Manual

npm run build
npm start

πŸ§‘β€πŸ’» Contributing

This 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:

  1. Create a feature branch (git checkout -b feature/your-feature)
  2. Keep commits scoped and descriptive
  3. Run npm run typecheck, npm test and npm run build before pushing. CI runs the same four checks, but it is quicker to find out locally

πŸ’¬ Contact


πŸ™‹ FAQ

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.


πŸ“„ Licence

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! πŸ₯

About

A modern web application for the Melbourne University Ultimate Frisbee Club, built with Next.js 15, TypeScript, and Tailwind CSS. This platform serves as the official website for club management, member communication, and community engagement.

Resources

Stars

102 stars

Watchers

8 watching

Forks

Releases

Packages

Contributors

Languages