diff --git a/src/app/(web)/(auth)/login/page.tsx b/src/app/(web)/(auth)/login/page.tsx index 3241bfa1..515fdad2 100644 --- a/src/app/(web)/(auth)/login/page.tsx +++ b/src/app/(web)/(auth)/login/page.tsx @@ -1,3 +1,85 @@ +'use client'; +import Link from 'next/link'; +import { loginAction } from '@/lib/auth/auth-service'; +import { useActionState, useEffect } from 'react'; + +export interface LoginState { + success: boolean; + errors: { + email?: string[]; + password?: string[]; + }; + message: string; +} + +const initialState: LoginState = { + success: true, + errors: {}, + message: '', +}; + export default function LoginPage() { - return
login
; + const [state, formAction] = useActionState(loginAction, initialState); + + useEffect(() => { + if (state && !state.success && Object.keys(state.errors).length === 0 && state.message) { + alert(state.message); + } + }, [state]); + + return ( +
+
+

Sign In

+ +
+
+
+ + + {state?.errors?.email && ( +

{state.errors.email[0]}

+ )} +
+ +
+ + + {state?.errors?.password && ( +

{state.errors.password[0]}

+ )} +
+
+ + + +
+

Don't have an account?

+ + Sign Up + +
+
+
+
+ ); } diff --git a/src/app/(web)/(auth)/signup/page.tsx b/src/app/(web)/(auth)/signup/page.tsx index e523d7d2..b39f9c55 100644 --- a/src/app/(web)/(auth)/signup/page.tsx +++ b/src/app/(web)/(auth)/signup/page.tsx @@ -1,3 +1,102 @@ +'use client'; +import Link from 'next/link'; +import { signup } from '@/lib/auth/auth-service'; +import { useFormState } from 'react-dom'; +import { useEffect } from 'react'; + +export interface SignupState { + success: boolean; + errors: { + name?: string[]; + email?: string[]; + password?: string[]; + }; + message: string; +} + +const initialState: SignupState = { + success: true, + errors: {}, + message: '', +}; + export default function SignupPage() { - return
signup
; + const [state, formAction] = useFormState(signup, initialState); + + useEffect(() => { + if (state && !state.success && Object.keys(state.errors).length === 0 && state.message) { + alert(state.message); + } + }, [state]); + + return ( +
+
+

Create an account

+ +
+
+
+ + + {state?.errors?.name && ( +

{state.errors.name[0]}

+ )} +
+ +
+ + + {state?.errors?.email && ( +

{state.errors.email[0]}

+ )} +
+ +
+ + + {state?.errors?.password && ( +

{state.errors.password[0]}

+ )} +
+
+ + + +
+

Already have an account?

+ + Sign In + +
+
+
+
+ ); } diff --git a/src/app/(web)/(crm)/dashboard/page.tsx b/src/app/(web)/(crm)/dashboard/page.tsx new file mode 100644 index 00000000..f3907fdc --- /dev/null +++ b/src/app/(web)/(crm)/dashboard/page.tsx @@ -0,0 +1,7 @@ +import { getCurrentUser } from '@/lib/auth/auth-service'; + +export default async function DashboardPage() { + const name = await getCurrentUser(); + + return
{name?.name}
; +} diff --git a/src/app/(web)/dashboard/page.tsx b/src/app/(web)/dashboard/page.tsx deleted file mode 100644 index 7057af71..00000000 --- a/src/app/(web)/dashboard/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function DashboardPage() { - return
; -} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts deleted file mode 100644 index 942b0f06..00000000 --- a/src/app/api/auth/login/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type NextRequest } from 'next/server'; -import { sargeApiError, sargeApiResponse } from '@/lib/responses'; -import { login } from '@/lib/auth/auth-service'; -import { InvalidInputError } from '@/lib/schemas/errors'; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - - if (!body.email || !body.password) { - return sargeApiError('Email and password are required', 400); - } - - const user = await login({ - email: body.email, - password: body.password, - }); - - return sargeApiResponse(user, 200); - } catch (error) { - if (error instanceof InvalidInputError) { - return sargeApiError(error.message, 400); - } - - const message = error instanceof Error ? error.message : String(error); - - if (message.includes('Invalid credentials')) { - return sargeApiError('Invalid email or password', 401); - } - - if (message.includes('Login implementation needed')) { - return sargeApiError('Authentication not yet configured', 501); - } - - return sargeApiError(message, 500); - } -} diff --git a/src/app/globals.css b/src/app/globals.css index 3af1742e..9e158c36 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,31 @@ @import 'tailwindcss'; +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap'); :root { - --background: #ffffff; - --foreground: #171717; + --background: #171717; + --foreground: #ffffff; + --sarge-purple: #5d5bf7; + --sarge-lightgrey: #f3f3f5; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); + --color-s-purple: var(--sarge-purple); + --color-s-lightgrey: var(--sarge-lightgrey); + --font-sans: 'Inter', 'system-ui'; --font-mono: var(--font-geist-mono); } @media (prefers-color-scheme: dark) { :root { - --background: #0a0a0a; - --foreground: #ededed; + --background: #ffffff; + --foreground: #0a0a0a; } } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a1e14ec4..801d84ab 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'Sarge', + description: 'Standardized Assessment Review, Grading, & Evaluation', }; export default function RootLayout({ diff --git a/src/lib/auth/auth-client.tsx b/src/lib/auth/auth-client.tsx new file mode 100644 index 00000000..871bec3f --- /dev/null +++ b/src/lib/auth/auth-client.tsx @@ -0,0 +1,3 @@ +'use client'; + +// We need to create a React context to get the current user on the frontend in the authenticated pages diff --git a/src/lib/auth/auth-service.ts b/src/lib/auth/auth-service.ts index b7f37d3a..0191190c 100644 --- a/src/lib/auth/auth-service.ts +++ b/src/lib/auth/auth-service.ts @@ -1,4 +1,4 @@ -import 'server-only'; +'use server'; import { createSession, deleteSession, verifySession } from './auth'; import { redirect } from 'next/navigation'; @@ -7,13 +7,84 @@ import { prisma } from '../prisma'; import bcrypt from 'bcrypt'; import { AuthorizationError } from '../schemas/errors'; import { type User } from '@/generated/prisma'; +import { createUserSchema, loginUserSchema } from '../schemas/user.schema'; +import { type SignupState } from '@/app/(web)/(auth)/signup/page'; +import { type LoginState } from '@/app/(web)/(auth)/login/page'; -export interface LoginCredentials { - email: string; - password: string; +export async function signup( + prevState: SignupState | null, + formData: FormData +): Promise { + const parsedCreds = createUserSchema.safeParse({ + name: formData.get('fullName'), + email: formData.get('email'), + password: formData.get('password'), + }); + + if (!parsedCreds.success) { + return { + success: false, + errors: parsedCreds.error.flatten().fieldErrors, + message: 'Please check your input and try again.', + }; + } + + try { + const hashedPassword = await bcrypt.hash(parsedCreds.data.password, 10); + + const user = await prisma.user.create({ + data: { + name: parsedCreds.data.name, + email: parsedCreds.data.email, + hashedPassword, + }, + }); + + await createSession({ userId: user.id, email: user.email }); + } catch (error) { + console.error('Signup error:', error); + return { + success: false, + errors: {}, + message: 'There was an error creating your account. Please try again.', + }; + } + + redirect('/dashboard'); +} + +export async function loginAction( + prevState: LoginState | null, + formData: FormData +): Promise { + const parsedCreds = loginUserSchema.safeParse({ + email: formData.get('email'), + password: formData.get('password'), + }); + + if (!parsedCreds.success) { + return { + success: false, + errors: parsedCreds.error.flatten().fieldErrors, + message: 'Please check your input and try again.', + }; + } + + try { + await login(parsedCreds.data); + } catch (error) { + console.error('Login error:', error); + return { + success: false, + errors: {}, + message: 'Invalid email or password. Please try again.', + }; + } + + redirect('/dashboard'); } -export async function login(_credentials: LoginCredentials): Promise { +export async function login(_credentials: { email: string; password: string }): Promise { const user = await prisma.user.findUnique({ where: { email: _credentials.email, diff --git a/src/lib/controllers/user.controller.ts b/src/lib/controllers/user.controller.ts index 08e7cc8a..ef0be618 100644 --- a/src/lib/controllers/user.controller.ts +++ b/src/lib/controllers/user.controller.ts @@ -15,7 +15,11 @@ class UserController { try { const validatedUser = createUserSchema.parse(user); return await prisma.user.create({ - data: validatedUser, + data: { + name: validatedUser.name, + email: validatedUser.email, + hashedPassword: validatedUser.password, + }, }); } catch (error) { if (error instanceof z.ZodError) { diff --git a/src/lib/schemas/user.schema.ts b/src/lib/schemas/user.schema.ts index 9b91d669..d08ac838 100644 --- a/src/lib/schemas/user.schema.ts +++ b/src/lib/schemas/user.schema.ts @@ -17,7 +17,12 @@ export const createUserSchema = z.object({ .toLowerCase() .trim() .max(255, 'Email must be less than 255 characters'), - hashedPassword: z.string(), + password: z.string().min(8), +}); + +export const loginUserSchema = z.object({ + email: createUserSchema.shape.email, + password: z.string().min(8, 'Password is required'), }); export const UserSchema = z.object({ @@ -30,3 +35,5 @@ export const UserSchema = z.object({ export type UserDTO = z.infer; export type CreateUserDTO = z.infer; + +export type LoginUserDTO = z.infer; diff --git a/src/middleware.ts b/src/middleware.ts index 7c8d1249..185e573b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -17,6 +17,7 @@ export async function middleware(request: NextRequest) { await jwtVerify(sessionCookie, secret, { issuer: 'sargenu', }); + isAuthenticated = true; } catch { isAuthenticated = false;