Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/api/src/middleware/auth.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { NextFunction, Request, Response } from 'express';
import { UserRole } from '@mixmatch/types';
import {
JsonWebTokenError,
TokenExpiredError,
verifyToken,
} from '../services/jwt.service';

export interface AuthenticatedRequestUser {
userId: string;
role: UserRole;
}

const extractBearerToken = (authorizationHeader?: string): string | null => {
if (!authorizationHeader) {
return null;
}

const [scheme, token] = authorizationHeader.split(' ');

if (scheme !== 'Bearer' || !token) {
return null;
}

return token;
};

export const requireAuth = (req: Request, res: Response, next: NextFunction): void => {
const token = extractBearerToken(req.header('authorization'));

if (!token) {
res.status(401).json({ message: 'Unauthorized: missing or invalid token' });
return;
}

try {
const payload = verifyToken(token);

req.user = {
userId: payload.userId,
role: payload.role,
};

next();
} catch (error) {
if (error instanceof TokenExpiredError || error instanceof JsonWebTokenError) {
res.status(401).json({ message: 'Unauthorized: missing or invalid token' });
return;
}

res.status(500).json({ message: 'Internal server error' });
}
};

export const requireRole = (roles: UserRole[]) => {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ message: 'Unauthorized: missing or invalid token' });
return;
}

if (!roles.includes(req.user.role)) {
res.status(403).json({ message: 'Forbidden: insufficient role permissions' });
return;
}

next();
};
};
50 changes: 49 additions & 1 deletion apps/api/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import bcrypt from 'bcryptjs';
import User from '../users/user.model';
import { generateToken } from '../../services/jwt.service';
import { registerSchema } from './auth.validation';
import { loginSchema, registerSchema } from './auth.validation';

const SALT_ROUNDS = 10;

Expand Down Expand Up @@ -68,3 +68,51 @@
res.status(500).json({ message: 'Internal server error' });
}
};

export const login = async (req: Request, res: Response): Promise<void> => {
const parsedPayload = loginSchema.safeParse(req.body);

if (!parsedPayload.success) {
res.status(400).json({
message: 'Validation failed',
errors: parsedPayload.error.flatten(),
});
return;
}

const { email, password } = parsedPayload.data;

try {
const normalizedEmail = email.toLowerCase();
const existingUser = await User.findOne({ email: normalizedEmail });

if (!existingUser) {
res.status(401).json({ message: 'Invalid email or password' });
return;
}

const passwordMatches = await bcrypt.compare(password, existingUser.passwordHash);

if (!passwordMatches) {
res.status(401).json({ message: 'Invalid email or password' });
return;
}

const token = generateToken(existingUser.id, existingUser.role);

res.status(200).json({
token,
user: {
id: existingUser.id,
name: existingUser.name,
email: existingUser.email,
role: existingUser.role,
onboardingCompleted: existingUser.onboardingCompleted,
createdAt: existingUser.createdAt,
updatedAt: existingUser.updatedAt,
},
});
} catch (error) {

Check warning on line 115 in apps/api/src/modules/auth/auth.controller.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and Build

'error' is defined but never used
res.status(500).json({ message: 'Internal server error' });
}
};
3 changes: 2 additions & 1 deletion apps/api/src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Router } from 'express';
import { register } from './auth.controller';
import { login, register } from './auth.controller';

const authRouter = Router();

authRouter.post('/register', register);
authRouter.post('/login', login);

export default authRouter;
6 changes: 6 additions & 0 deletions apps/api/src/modules/auth/auth.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,10 @@ export const registerSchema = z.object({
role: z.enum([UserRole.DJ, UserRole.PLANNER, UserRole.MUSIC_LOVER]),
});

export const loginSchema = z.object({
email: z.string().trim().email(),
password: z.string().min(1, 'Password is required'),
});

export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
11 changes: 11 additions & 0 deletions apps/api/src/types/express.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { AuthenticatedRequestUser } from '../middleware/auth.middleware';

declare global {
namespace Express {

Check warning on line 4 in apps/api/src/types/express.d.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and Build

'Express' is defined but never used
interface Request {

Check warning on line 5 in apps/api/src/types/express.d.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and Build

'Request' is defined but never used
user?: AuthenticatedRequestUser;
}
}
}

export {};
15 changes: 1 addition & 14 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
Expand All @@ -24,9 +13,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className="antialiased">
{children}
</body>
</html>
Expand Down
64 changes: 4 additions & 60 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,9 @@
import Image from "next/image";
import { AuthStoreDemo } from '@/components/auth-store-demo';

export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<main className="flex min-h-screen items-center justify-center bg-zinc-50 px-6 py-16">
<AuthStoreDemo />
</main>
);
}
49 changes: 49 additions & 0 deletions apps/web/components/auth-store-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import { UserRole } from '@mixmatch/types';
import { useAuthStore } from '@/store/auth.store';

export function AuthStoreDemo() {
const user = useAuthStore((state) => state.user);
const role = useAuthStore((state) => state.role);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login);
const logout = useAuthStore((state) => state.logout);

return (
<section className="w-full max-w-2xl rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-zinc-900">Auth Store Snapshot</h2>
<p className="mt-3 text-sm text-zinc-700">isAuthenticated: {String(isAuthenticated)}</p>
<p className="text-sm text-zinc-700">role: {role ?? 'NONE'}</p>
<p className="text-sm text-zinc-700">user: {user ? `${user.name} (${user.email})` : 'NONE'}</p>

<div className="mt-4 flex gap-3">
<button
type="button"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white"
onClick={() => {
login({
token: 'demo-token',
user: {
id: 'demo-user-id',
name: 'Demo DJ',
email: 'demo@mixmatch.io',
role: UserRole.DJ,
onboardingCompleted: false,
},
});
}}
>
Mock Login
</button>
<button
type="button"
className="rounded-md border border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-800"
onClick={logout}
>
Logout
</button>
</div>
</section>
);
}
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"dependencies": {
"next": "16.1.4",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"zustand": "^5.0.8"
},
"devDependencies": {
"@mixmatch/config": "workspace:^",
Expand Down
68 changes: 68 additions & 0 deletions apps/web/store/auth.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client';

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { UserRole } from '@mixmatch/types';

export interface AuthUser {
id: string;
name: string;
email: string;
role: UserRole;
onboardingCompleted: boolean;
}

interface LoginPayload {
user: AuthUser;
token?: string;
}

interface AuthStore {
user: AuthUser | null;
role: UserRole | null;
token: string | null;
isAuthenticated: boolean;
login: (data: LoginPayload) => void;
logout: () => void;
}

export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
role: null,
token: null,
isAuthenticated: false,
login: ({ user, token }) => {
set({
user,
role: user.role,
token: token ?? null,
isAuthenticated: true,
});
},
logout: () => {
set({
user: null,
role: null,
token: null,
isAuthenticated: false,
});

if (typeof window !== 'undefined') {
window.location.assign('/');
}
},
}),
{
name: 'mixmatch-auth-store',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
user: state.user,
role: state.role,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
},
),
);
Loading
Loading