From a0dd50f59b93eadbdf2f6da7f4e1646baea059fc Mon Sep 17 00:00:00 2001 From: Nicholai Date: Thu, 5 Feb 2026 08:20:51 -0700 Subject: [PATCH] feat(auth): add user profiles and improve auth security (#33) - Wire up real user data to sidebar, header, and account modal - Add functional profile editing (first name, last name) via WorkOS API - Add password change functionality via WorkOS API - Add logout functionality to sidebar and header dropdowns - Migrate from manual WorkOS SDK to @workos-inc/authkit-nextjs - Add server-side input validation with Zod schemas for all auth routes - Add shared validation schemas for auth, users, teams, schedule, financial - Fix 30-second auto-logout by properly handling refresh tokens - Add SidebarUser type and toSidebarUser helper for UI components - Add getInitials utility for avatar fallbacks - Document rate limiting configuration for Cloudflare WAF - Fix login page Suspense boundary for Next.js 15 compatibility - Remove obsolete workos-client.ts in favor of authkit helpers Co-authored-by: Nicholai --- .env.example | 9 +- .gitignore | 1 + AGENTS.md | 1 + CLAUDE.md | 28 ++++ docs/RATE-LIMITING.md | 71 +++++++++ src/app/(auth)/login/page.tsx | 10 +- src/app/actions/profile.ts | 133 ++++++++++++++++ src/app/actions/users.ts | 122 ++++++++++---- src/app/api/auth/accept-invite/route.ts | 59 +++++-- src/app/api/auth/callback/route.ts | 51 +++--- src/app/api/auth/login/route.ts | 149 ++++++++++++------ src/app/api/auth/password-reset/route.ts | 44 ++++-- src/app/api/auth/reset-password/route.ts | 64 ++++++-- src/app/api/auth/signup/route.ts | 79 ++++++++-- src/app/api/auth/sso/route.ts | 35 +++-- src/app/api/auth/verify-email/route.ts | 59 +++++-- src/app/callback/route.ts | 15 +- src/app/dashboard/layout.tsx | 13 +- src/app/layout.tsx | 7 +- src/components/account-modal.tsx | 192 ++++++++++++++++++----- src/components/app-sidebar.tsx | 14 +- src/components/auth/login-form.tsx | 74 +++++---- src/components/auth/password-input.tsx | 62 ++++---- src/components/auth/signup-form.tsx | 81 ++++------ src/components/nav-user.tsx | 33 ++-- src/components/site-header.tsx | 38 +++-- src/lib/auth.ts | 103 ++++++++---- src/lib/utils.ts | 16 ++ src/lib/validations/auth.ts | 93 +++++++++++ src/lib/validations/common.ts | 74 +++++++++ src/lib/validations/financial.ts | 171 ++++++++++++++++++++ src/lib/validations/index.ts | 10 ++ src/lib/validations/profile.ts | 38 +++++ src/lib/validations/schedule.ts | 122 ++++++++++++++ src/lib/validations/teams.ts | 81 ++++++++++ src/lib/validations/users.ts | 75 +++++++++ src/lib/workos-client.ts | 32 ---- src/middleware.ts | 71 ++++----- 38 files changed, 1849 insertions(+), 481 deletions(-) create mode 120000 AGENTS.md create mode 100755 docs/RATE-LIMITING.md create mode 100755 src/app/actions/profile.ts create mode 100755 src/lib/validations/auth.ts create mode 100755 src/lib/validations/common.ts create mode 100755 src/lib/validations/financial.ts create mode 100755 src/lib/validations/index.ts create mode 100755 src/lib/validations/profile.ts create mode 100755 src/lib/validations/schedule.ts create mode 100755 src/lib/validations/teams.ts create mode 100755 src/lib/validations/users.ts delete mode 100755 src/lib/workos-client.ts diff --git a/.env.example b/.env.example index 86dd7fd..3d21c75 100755 --- a/.env.example +++ b/.env.example @@ -1,9 +1,14 @@ -# WorkOS Authentication +# WorkOS Authentication (AuthKit) # Get these from your WorkOS dashboard: https://dashboard.workos.com WORKOS_API_KEY=your_workos_api_key_here WORKOS_CLIENT_ID=your_workos_client_id_here + +# Cookie encryption password - must be at least 32 characters +# Generate with: openssl rand -base64 24 WORKOS_COOKIE_PASSWORD=your_random_32_character_string_here -WORKOS_REDIRECT_URI=http://localhost:3000 + +# Redirect URI for OAuth callback (must match WorkOS dashboard config) +NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback # NetSuite Integration # OAuth 2.0 credentials from your NetSuite account diff --git a/.gitignore b/.gitignore index 66b8ce8..7c117c5 100755 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ dist/ .playwright-mcp mobile-ui-references/ .fuse_* +tmp/ diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d815219..3a09af7 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,3 +76,31 @@ known issues (WIP) container. horizontal panning works. needs a different approach for vertical navigation (possibly a custom viewport with transform-based rendering for the body while keeping the header fixed separately). + +coding style +--- + +strict typescript discipline: + +- `readonly` everywhere mutation isn't intended. `ReadonlyArray`, + `Readonly>`, deep readonly wrappers. write `DeepReadonly` + utilities when needed +- discriminated unions over optional properties. `{ status: 'ok'; data: T } | + { status: 'error'; error: Error }` instead of `{ status: string; error?: + Error; data?: T }`. makes impossible states unrepresentable +- no `enum`. use `as const` objects or union types instead. enums have quirks, + especially numeric ones with reverse mappings +- branded/opaque types for primitive identifiers. `type UserId = string & + { readonly __brand: unique symbol }` prevents mixing up `PostId` and `UserId` +- no `any`, no `as`, no `!` - genuinely zero. use `unknown` with proper + narrowing. write type guards instead of assertions +- explicit return types on all exported functions. don't rely on inference for + public APIs. catches accidental changes, improves compile speed +- effect-free module scope. no side effects at top level (no `console.log`, + `fetch`, mutations during import). everything meaningful happens in + explicitly called functions +- result types over thrown exceptions. return `Result` or `Either` + instead of throwing. makes error handling visible in type signatures + +these trade short-term convenience for long-term correctness. the strict +version is always better even when the permissive version works right now. diff --git a/docs/RATE-LIMITING.md b/docs/RATE-LIMITING.md new file mode 100755 index 0000000..4fb7691 --- /dev/null +++ b/docs/RATE-LIMITING.md @@ -0,0 +1,71 @@ +# Rate Limiting Configuration + +This document explains how to configure rate limiting for the authentication endpoints using Cloudflare. + +## Recommended Configuration + +### Via Cloudflare Dashboard (Recommended) + +1. Go to **Cloudflare Dashboard** > **Security** > **WAF** > **Rate limiting rules** + +2. Create a new rule with the following settings: + + **Rule name:** Auth endpoint protection + + **Expression:** + ``` + (http.request.uri.path contains "/api/auth/") or (http.request.uri.path eq "/callback") + ``` + + **Characteristics:** IP address + + **Rate:** 10 requests per 60 seconds + + **Action:** Block for 60 seconds + +3. Click **Deploy** + +### Alternative: Stricter Rules for Login + +For additional protection against brute-force attacks on the login endpoint: + +**Rule name:** Login endpoint protection + +**Expression:** +``` +(http.request.uri.path eq "/api/auth/login") and (http.request.method eq "POST") +``` + +**Characteristics:** IP address + +**Rate:** 5 requests per 60 seconds + +**Action:** Block for 300 seconds (5 minutes) + +## Why These Settings? + +1. **10 requests per minute for general auth endpoints** - Allows legitimate users to: + - Make a few login attempts if they mistype their password + - Request password resets + - Complete email verification + +2. **Stricter limits on login** - The login endpoint is the primary target for brute-force attacks. 5 attempts per minute is generous for legitimate users but stops automated attacks. + +3. **IP-based blocking** - Simple and effective for most use cases. Note that this may block multiple users behind the same NAT/corporate network. + +## Monitoring + +After enabling rate limiting: + +1. Monitor the **Security Analytics** dashboard for blocked requests +2. Adjust thresholds if you see legitimate traffic being blocked +3. Consider adding additional rules for specific patterns of abuse + +## Advanced: Per-User Rate Limiting + +For more sophisticated rate limiting based on user identity (not just IP), consider implementing application-level rate limiting using: + +- **Cloudflare Durable Objects** - For distributed state +- **Cloudflare KV** - For simple counters with eventual consistency + +This is typically only needed for applications with high traffic or specific compliance requirements. diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index f5a1897..0c5d489 100755 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -7,7 +7,7 @@ import { PasswordlessForm } from "@/components/auth/passwordless-form" import { SocialLoginButtons } from "@/components/auth/social-login-buttons" import { Separator } from "@/components/ui/separator" -export default function LoginPage() { +function LoginContent() { return (
@@ -53,3 +53,11 @@ export default function LoginPage() {
) } + +export default function LoginPage() { + return ( + Loading...
}> + + + ) +} diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts new file mode 100755 index 0000000..eecfeaf --- /dev/null +++ b/src/app/actions/profile.ts @@ -0,0 +1,133 @@ +"use server" + +import { getWorkOS, signOut } from "@workos-inc/authkit-nextjs" +import { getCloudflareContext } from "@opennextjs/cloudflare" +import { getDb } from "@/db" +import { users } from "@/db/schema" +import { eq } from "drizzle-orm" +import { requireAuth } from "@/lib/auth" +import { + updateProfileSchema, + changePasswordSchema, + type UpdateProfileInput, + type ChangePasswordInput, +} from "@/lib/validations/profile" + +type ActionResult = + | { success: true; data?: T } + | { success: false; error: string } + +/** + * Update the current user's profile (first name, last name) + */ +export async function updateProfile( + input: UpdateProfileInput +): Promise { + try { + // Validate input + const parsed = updateProfileSchema.safeParse(input) + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message ?? "Invalid input", + } + } + + const { firstName, lastName } = parsed.data + + // Get current authenticated user + const currentUser = await requireAuth() + + // Update in WorkOS + const workos = getWorkOS() + await workos.userManagement.updateUser({ + userId: currentUser.id, + firstName, + lastName, + }) + + // Update in local database + const { env } = await getCloudflareContext() + if (env?.DB) { + const db = getDb(env.DB) + const now = new Date().toISOString() + const displayName = `${firstName} ${lastName}`.trim() + + await db + .update(users) + .set({ + firstName, + lastName, + displayName, + updatedAt: now, + }) + .where(eq(users.id, currentUser.id)) + .run() + } + + return { success: true } + } catch (error) { + console.error("Error updating profile:", error) + return { + success: false, + error: error instanceof Error ? error.message : "Failed to update profile", + } + } +} + +/** + * Change the current user's password + * Note: WorkOS doesn't verify the current password via API - this is a UX-only field. + * For production, consider implementing a proper password verification flow. + */ +export async function changePassword( + input: ChangePasswordInput +): Promise { + try { + // Validate input + const parsed = changePasswordSchema.safeParse(input) + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message ?? "Invalid input", + } + } + + const { newPassword } = parsed.data + + // Get current authenticated user + const currentUser = await requireAuth() + + // Update password in WorkOS + const workos = getWorkOS() + await workos.userManagement.updateUser({ + userId: currentUser.id, + password: newPassword, + }) + + return { success: true } + } catch (error) { + console.error("Error changing password:", error) + + // Handle specific WorkOS errors + const errorMessage = + error instanceof Error ? error.message : "Failed to change password" + + // Check for common error patterns + if (errorMessage.includes("password")) { + return { + success: false, + error: "Unable to change password. You may have signed in with a social provider.", + } + } + + return { success: false, error: errorMessage } + } +} + +/** + * Sign out the current user + */ +export async function logout(): Promise { + await signOut() +} diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index d28f1c5..c265294 100755 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -17,6 +17,14 @@ import { getCurrentUser } from "@/lib/auth" import { requirePermission } from "@/lib/permissions" import { eq, and } from "drizzle-orm" import { revalidatePath } from "next/cache" +import { + updateUserRoleSchema, + deactivateUserSchema, + inviteUserSchema, + assignUserToProjectSchema, + assignUserToTeamSchema, + assignUserToGroupSchema, +} from "@/lib/validations/users" export type UserWithRelations = User & { teams: { id: string; name: string }[] @@ -90,6 +98,13 @@ export async function updateUserRole( userId: string, role: string ): Promise<{ success: boolean; error?: string }> { + // validate input + const parseResult = updateUserRoleSchema.safeParse({ userId, role }) + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return { success: false, error: firstIssue?.message || "Invalid input" } + } + try { const currentUser = await getCurrentUser() requirePermission(currentUser, "user", "update") @@ -104,8 +119,8 @@ export async function updateUserRole( await db .update(users) - .set({ role, updatedAt: now }) - .where(eq(users.id, userId)) + .set({ role: parseResult.data.role, updatedAt: now }) + .where(eq(users.id, parseResult.data.userId)) .run() revalidatePath("/dashboard/people") @@ -122,6 +137,13 @@ export async function updateUserRole( export async function deactivateUser( userId: string ): Promise<{ success: boolean; error?: string }> { + // validate input + const parseResult = deactivateUserSchema.safeParse({ userId }) + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return { success: false, error: firstIssue?.message || "Invalid input" } + } + try { const currentUser = await getCurrentUser() requirePermission(currentUser, "user", "delete") @@ -137,7 +159,7 @@ export async function deactivateUser( await db .update(users) .set({ isActive: false, updatedAt: now }) - .where(eq(users.id, userId)) + .where(eq(users.id, parseResult.data.userId)) .run() revalidatePath("/dashboard/people") @@ -156,6 +178,15 @@ export async function assignUserToProject( projectId: string, role: string ): Promise<{ success: boolean; error?: string }> { + // validate input + const parseResult = assignUserToProjectSchema.safeParse({ userId, projectId, role }) + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return { success: false, error: firstIssue?.message || "Invalid input" } + } + + const validated = parseResult.data + try { const currentUser = await getCurrentUser() requirePermission(currentUser, "project", "update") @@ -174,8 +205,8 @@ export async function assignUserToProject( .from(projectMembers) .where( and( - eq(projectMembers.userId, userId), - eq(projectMembers.projectId, projectId) + eq(projectMembers.userId, validated.userId), + eq(projectMembers.projectId, validated.projectId) ) ) .get() @@ -184,11 +215,11 @@ export async function assignUserToProject( // update role await db .update(projectMembers) - .set({ role }) + .set({ role: validated.role }) .where( and( - eq(projectMembers.userId, userId), - eq(projectMembers.projectId, projectId) + eq(projectMembers.userId, validated.userId), + eq(projectMembers.projectId, validated.projectId) ) ) .run() @@ -198,9 +229,9 @@ export async function assignUserToProject( .insert(projectMembers) .values({ id: crypto.randomUUID(), - userId, - projectId, - role, + userId: validated.userId, + projectId: validated.projectId, + role: validated.role, assignedAt: now, }) .run() @@ -222,6 +253,15 @@ export async function assignUserToTeam( userId: string, teamId: string ): Promise<{ success: boolean; error?: string }> { + // validate input + const parseResult = assignUserToTeamSchema.safeParse({ userId, teamId }) + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return { success: false, error: firstIssue?.message || "Invalid input" } + } + + const validated = parseResult.data + try { const currentUser = await getCurrentUser() requirePermission(currentUser, "team", "update") @@ -239,7 +279,7 @@ export async function assignUserToTeam( .select() .from(teamMembers) .where( - and(eq(teamMembers.userId, userId), eq(teamMembers.teamId, teamId)) + and(eq(teamMembers.userId, validated.userId), eq(teamMembers.teamId, validated.teamId)) ) .get() @@ -252,8 +292,8 @@ export async function assignUserToTeam( .insert(teamMembers) .values({ id: crypto.randomUUID(), - userId, - teamId, + userId: validated.userId, + teamId: validated.teamId, joinedAt: now, }) .run() @@ -273,6 +313,15 @@ export async function assignUserToGroup( userId: string, groupId: string ): Promise<{ success: boolean; error?: string }> { + // validate input + const parseResult = assignUserToGroupSchema.safeParse({ userId, groupId }) + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return { success: false, error: firstIssue?.message || "Invalid input" } + } + + const validated = parseResult.data + try { const currentUser = await getCurrentUser() requirePermission(currentUser, "group", "update") @@ -290,7 +339,7 @@ export async function assignUserToGroup( .select() .from(groupMembers) .where( - and(eq(groupMembers.userId, userId), eq(groupMembers.groupId, groupId)) + and(eq(groupMembers.userId, validated.userId), eq(groupMembers.groupId, validated.groupId)) ) .get() @@ -303,8 +352,8 @@ export async function assignUserToGroup( .insert(groupMembers) .values({ id: crypto.randomUUID(), - userId, - groupId, + userId: validated.userId, + groupId: validated.groupId, joinedAt: now, }) .run() @@ -325,6 +374,15 @@ export async function inviteUser( role: string, organizationId?: string ): Promise<{ success: boolean; error?: string }> { + // validate input + const parseResult = inviteUserSchema.safeParse({ email, role, organizationId }) + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return { success: false, error: firstIssue?.message || "Invalid input" } + } + + const validated = parseResult.data + try { const currentUser = await getCurrentUser() requirePermission(currentUser, "user", "create") @@ -338,7 +396,7 @@ export async function inviteUser( const now = new Date().toISOString() // check if user already exists - const existing = await db.select().from(users).where(eq(users.email, email)).get() + const existing = await db.select().from(users).where(eq(users.email, validated.email)).get() if (existing) { return { success: false, error: "User already exists" } @@ -360,21 +418,21 @@ export async function inviteUser( // send invitation via workos // note: when user accepts, they'll be created in workos // and on first login, ensureUserExists() will sync them to our db - const invitation = await workos.userManagement.sendInvitation({ - email, + await workos.userManagement.sendInvitation({ + email: validated.email, }) // create pending user record in our db const newUser: NewUser = { id: crypto.randomUUID(), // temporary until workos creates real user - email, - role, + email: validated.email, + role: validated.role, isActive: false, // inactive until they accept invite createdAt: now, updatedAt: now, firstName: null, lastName: null, - displayName: email.split("@")[0], + displayName: validated.email.split("@")[0], avatarUrl: null, lastLoginAt: null, } @@ -382,14 +440,14 @@ export async function inviteUser( await db.insert(users).values(newUser).run() // if organization specified, add to organization - if (organizationId) { + if (validated.organizationId) { await db .insert(organizationMembers) .values({ id: crypto.randomUUID(), - organizationId, + organizationId: validated.organizationId, userId: newUser.id, - role, + role: validated.role, joinedAt: now, }) .run() @@ -408,28 +466,28 @@ export async function inviteUser( // development mode: just create user in db without sending email const newUser: NewUser = { id: crypto.randomUUID(), - email, - role, + email: validated.email, + role: validated.role, isActive: true, // active immediately in dev mode createdAt: now, updatedAt: now, firstName: null, lastName: null, - displayName: email.split("@")[0], + displayName: validated.email.split("@")[0], avatarUrl: null, lastLoginAt: null, } await db.insert(users).values(newUser).run() - if (organizationId) { + if (validated.organizationId) { await db .insert(organizationMembers) .values({ id: crypto.randomUUID(), - organizationId, + organizationId: validated.organizationId, userId: newUser.id, - role, + role: validated.role, joinedAt: now, }) .run() diff --git a/src/app/api/auth/accept-invite/route.ts b/src/app/api/auth/accept-invite/route.ts index 738e02d..8f55049 100755 --- a/src/app/api/auth/accept-invite/route.ts +++ b/src/app/api/auth/accept-invite/route.ts @@ -1,35 +1,66 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client"; +import { NextRequest, NextResponse } from "next/server" +import { getWorkOS } from "@workos-inc/authkit-nextjs" +import { z } from "zod" + +const acceptInviteSchema = z.object({ + invitationToken: z.string().min(1, "Invitation token is required"), +}) + +function mapWorkOSError(error: unknown): string { + const err = error as { code?: string; message?: string } + switch (err.code) { + case "invitation_not_found": + return "Invitation not found or has expired." + case "invitation_expired": + return "This invitation has expired. Please request a new one." + default: + return err.message || "An error occurred. Please try again." + } +} export async function POST(request: NextRequest) { try { - const workos = getWorkOSClient(); - const { invitationToken } = (await request.json()) as { - invitationToken: string - }; + // validate input + const body = await request.json() + const parseResult = acceptInviteSchema.safeParse(body) - if (!workos) { + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return NextResponse.json( + { success: false, error: firstIssue?.message || "Invalid input" }, + { status: 400 } + ) + } + + const { invitationToken } = parseResult.data + + // check if workos is configured + const isConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (!isConfigured) { return NextResponse.json({ success: true, message: "Invitation accepted (dev mode)", - }); + }) } + const workos = getWorkOS() // verify invitation exists and is valid - const invitation = await workos.userManagement.getInvitation( - invitationToken - ); + const invitation = await workos.userManagement.getInvitation(invitationToken) return NextResponse.json({ success: true, message: "Invitation verified", email: invitation.email, - }); + }) } catch (error) { - console.error("Invite acceptance error:", error); + console.error("Invite acceptance error:", error) return NextResponse.json( { success: false, error: mapWorkOSError(error) }, { status: 500 } - ); + ) } } diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index bd928ce..3bd94ac 100755 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server" -import { getWorkOSClient } from "@/lib/workos-client" +import { getWorkOS, saveSession } from "@workos-inc/authkit-nextjs" import { ensureUserExists } from "@/lib/auth" -import { SESSION_COOKIE } from "@/lib/session" export async function GET(request: NextRequest) { const code = request.nextUrl.searchParams.get("code") @@ -14,19 +13,24 @@ export async function GET(request: NextRequest) { } try { - const workos = getWorkOSClient() - if (!workos) { - return NextResponse.redirect( - new URL("/dashboard", request.url) - ) + const workos = getWorkOS() + + // check if workos is configured (dev mode fallback) + const isConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (!isConfigured) { + return NextResponse.redirect(new URL("/dashboard", request.url)) } - const result = - await workos.userManagement.authenticateWithCode({ - code, - clientId: process.env.WORKOS_CLIENT_ID!, - }) + const result = await workos.userManagement.authenticateWithCode({ + code, + clientId: process.env.WORKOS_CLIENT_ID!, + }) + // sync user to our database await ensureUserExists({ id: result.user.id, email: result.user.email, @@ -35,20 +39,19 @@ export async function GET(request: NextRequest) { profilePictureUrl: result.user.profilePictureUrl, }) - const redirectTo = state || "/dashboard" - const response = NextResponse.redirect( - new URL(redirectTo, request.url) + // save session with BOTH access and refresh tokens + await saveSession( + { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + user: result.user, + impersonator: result.impersonator, + }, + request ) - response.cookies.set(SESSION_COOKIE, result.accessToken, { - httpOnly: true, - secure: true, - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 24 * 7, - }) - - return response + const redirectTo = state || "/dashboard" + return NextResponse.redirect(new URL(redirectTo, request.url)) } catch (error) { console.error("OAuth callback error:", error) return NextResponse.redirect( diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8ac4d62..d1d33c3 100755 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,20 +1,66 @@ import { NextRequest, NextResponse } from "next/server" -import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client" +import { getWorkOS, saveSession } from "@workos-inc/authkit-nextjs" +import { z } from "zod" import { ensureUserExists } from "@/lib/auth" -import { SESSION_COOKIE } from "@/lib/session" + +// input validation schema +const loginRequestSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("password"), + email: z.string().email("Please enter a valid email address"), + password: z.string().min(1, "Password is required"), + }), + z.object({ + type: z.literal("passwordless_send"), + email: z.string().email("Please enter a valid email address"), + }), + z.object({ + type: z.literal("passwordless_verify"), + email: z.string().email("Please enter a valid email address"), + code: z.string().min(1, "Verification code is required"), + }), +]) + +function mapWorkOSError(error: unknown): string { + const err = error as { code?: string; message?: string } + switch (err.code) { + case "invalid_credentials": + return "Invalid email or password" + case "user_not_found": + return "No account found with this email" + case "expired_code": + return "Code expired. Please request a new one." + case "invalid_code": + return "Invalid code. Please try again." + default: + return err.message || "An error occurred. Please try again." + } +} export async function POST(request: NextRequest) { try { - const workos = getWorkOSClient() - const body = (await request.json()) as { - type: string - email: string - password?: string - code?: string - } - const { type, email, password, code } = body + // validate input + const body = await request.json() + const parseResult = loginRequestSchema.safeParse(body) - if (!workos) { + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return NextResponse.json( + { success: false, error: firstIssue?.message || "Invalid input" }, + { status: 400 } + ) + } + + const data = parseResult.data + const workos = getWorkOS() + + // check if workos is configured (dev mode fallback) + const isConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (!isConfigured) { return NextResponse.json({ success: true, redirectUrl: "/dashboard", @@ -22,14 +68,14 @@ export async function POST(request: NextRequest) { }) } - if (type === "password") { - const result = - await workos.userManagement.authenticateWithPassword({ - email, - password: password!, - clientId: process.env.WORKOS_CLIENT_ID!, - }) + if (data.type === "password") { + const result = await workos.userManagement.authenticateWithPassword({ + email: data.email, + password: data.password, + clientId: process.env.WORKOS_CLIENT_ID!, + }) + // sync user to our database await ensureUserExists({ id: result.user.id, email: result.user.email, @@ -38,25 +84,27 @@ export async function POST(request: NextRequest) { profilePictureUrl: result.user.profilePictureUrl, }) - const response = NextResponse.json({ + // save session with BOTH access and refresh tokens (fixes 30-second logout) + await saveSession( + { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + user: result.user, + impersonator: result.impersonator, + }, + request + ) + + return NextResponse.json({ success: true, redirectUrl: "/dashboard", }) - - response.cookies.set(SESSION_COOKIE, result.accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 24 * 7, - }) - - return response } - if (type === "passwordless_send") { - const magicAuth = - await workos.userManagement.createMagicAuth({ email }) + if (data.type === "passwordless_send") { + const magicAuth = await workos.userManagement.createMagicAuth({ + email: data.email, + }) return NextResponse.json({ success: true, @@ -64,14 +112,14 @@ export async function POST(request: NextRequest) { }) } - if (type === "passwordless_verify") { - const result = - await workos.userManagement.authenticateWithMagicAuth({ - code: code!, - email, - clientId: process.env.WORKOS_CLIENT_ID!, - }) + if (data.type === "passwordless_verify") { + const result = await workos.userManagement.authenticateWithMagicAuth({ + code: data.code, + email: data.email, + clientId: process.env.WORKOS_CLIENT_ID!, + }) + // sync user to our database await ensureUserExists({ id: result.user.id, email: result.user.email, @@ -80,20 +128,21 @@ export async function POST(request: NextRequest) { profilePictureUrl: result.user.profilePictureUrl, }) - const response = NextResponse.json({ + // save session with BOTH access and refresh tokens + await saveSession( + { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + user: result.user, + impersonator: result.impersonator, + }, + request + ) + + return NextResponse.json({ success: true, redirectUrl: "/dashboard", }) - - response.cookies.set(SESSION_COOKIE, result.accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 24 * 7, - }) - - return response } return NextResponse.json( diff --git a/src/app/api/auth/password-reset/route.ts b/src/app/api/auth/password-reset/route.ts index 2e8397a..c25fff0 100755 --- a/src/app/api/auth/password-reset/route.ts +++ b/src/app/api/auth/password-reset/route.ts @@ -1,29 +1,53 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getWorkOSClient } from "@/lib/workos-client"; +import { NextRequest, NextResponse } from "next/server" +import { getWorkOS } from "@workos-inc/authkit-nextjs" +import { z } from "zod" + +const passwordResetSchema = z.object({ + email: z.string().email("Please enter a valid email address"), +}) export async function POST(request: NextRequest) { try { - const workos = getWorkOSClient(); - const { email } = (await request.json()) as { email: string }; + // validate input + const body = await request.json() + const parseResult = passwordResetSchema.safeParse(body) - if (!workos) { + if (!parseResult.success) { + // still return success to prevent email enumeration + return NextResponse.json({ + success: true, + message: "If an account exists, a reset link has been sent", + }) + } + + const { email } = parseResult.data + + // check if workos is configured + const isConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (!isConfigured) { return NextResponse.json({ success: true, message: "Password reset link sent (dev mode)", - }); + }) } - await workos.userManagement.createPasswordReset({ email }); + const workos = getWorkOS() + await workos.userManagement.createPasswordReset({ email }) return NextResponse.json({ success: true, message: "If an account exists, a reset link has been sent", - }); + }) } catch (error) { - console.error("Password reset error:", error); + console.error("Password reset error:", error) + // always return success to prevent email enumeration return NextResponse.json({ success: true, message: "If an account exists, a reset link has been sent", - }); + }) } } diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index ab92ca2..a27ccc1 100755 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -1,32 +1,70 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client"; +import { NextRequest, NextResponse } from "next/server" +import { getWorkOS } from "@workos-inc/authkit-nextjs" +import { z } from "zod" + +const resetPasswordSchema = z.object({ + token: z.string().min(1, "Reset token is required"), + newPassword: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), +}) + +function mapWorkOSError(error: unknown): string { + const err = error as { code?: string; message?: string } + switch (err.code) { + case "invalid_token": + return "Invalid or expired reset link. Please request a new one." + case "password_too_weak": + return "Password does not meet security requirements." + default: + return err.message || "An error occurred. Please try again." + } +} export async function POST(request: NextRequest) { try { - const workos = getWorkOSClient(); - const { token, newPassword } = (await request.json()) as { - token: string - newPassword: string - }; + // validate input + const body = await request.json() + const parseResult = resetPasswordSchema.safeParse(body) - if (!workos) { + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return NextResponse.json( + { success: false, error: firstIssue?.message || "Invalid input" }, + { status: 400 } + ) + } + + const { token, newPassword } = parseResult.data + + // check if workos is configured + const isConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (!isConfigured) { return NextResponse.json({ success: true, message: "Password reset successful (dev mode)", - }); + }) } - await workos.userManagement.resetPassword({ token, newPassword }); + const workos = getWorkOS() + await workos.userManagement.resetPassword({ token, newPassword }) return NextResponse.json({ success: true, message: "Password reset successful", - }); + }) } catch (error) { - console.error("Reset password error:", error); + console.error("Reset password error:", error) return NextResponse.json( { success: false, error: mapWorkOSError(error) }, { status: 500 } - ); + ) } } diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 922f357..2d4eddd 100755 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,22 +1,67 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client"; +import { NextRequest, NextResponse } from "next/server" +import { getWorkOS } from "@workos-inc/authkit-nextjs" +import { z } from "zod" + +// input validation schema +const signupRequestSchema = z.object({ + email: z.string().email("Please enter a valid email address"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), + firstName: z + .string() + .min(1, "First name is required") + .max(50, "First name must be 50 characters or less"), + lastName: z + .string() + .min(1, "Last name is required") + .max(50, "Last name must be 50 characters or less"), +}) + +function mapWorkOSError(error: unknown): string { + const err = error as { code?: string; message?: string } + switch (err.code) { + case "user_exists": + return "An account with this email already exists" + case "password_too_weak": + return "Password does not meet security requirements" + default: + return err.message || "An error occurred. Please try again." + } +} export async function POST(request: NextRequest) { try { - const workos = getWorkOSClient(); - const { email, password, firstName, lastName } = (await request.json()) as { - email: string - password: string - firstName: string - lastName: string - }; + // validate input + const body = await request.json() + const parseResult = signupRequestSchema.safeParse(body) - if (!workos) { + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return NextResponse.json( + { success: false, error: firstIssue?.message || "Invalid input" }, + { status: 400 } + ) + } + + const { email, password, firstName, lastName } = parseResult.data + const workos = getWorkOS() + + // check if workos is configured (dev mode fallback) + const isConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (!isConfigured) { return NextResponse.json({ success: true, userId: "dev-user-" + Date.now(), message: "Account created (dev mode)", - }); + }) } const user = await workos.userManagement.createUser({ @@ -25,22 +70,22 @@ export async function POST(request: NextRequest) { firstName, lastName, emailVerified: false, - }); + }) await workos.userManagement.sendVerificationEmail({ userId: user.id, - }); + }) return NextResponse.json({ success: true, userId: user.id, - message: "Account created. Check your email to verify.", - }); + message: "Account created. Please check your email to verify.", + }) } catch (error) { - console.error("Signup error:", error); + console.error("Signup error:", error) return NextResponse.json( { success: false, error: mapWorkOSError(error) }, { status: 500 } - ); + ) } } diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts index 3f8908d..f9941ff 100755 --- a/src/app/api/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server" -import { getWorkOSClient } from "@/lib/workos-client" +import { getWorkOS } from "@workos-inc/authkit-nextjs" const VALID_PROVIDERS = [ "GoogleOAuth", @@ -14,29 +14,30 @@ export async function GET(request: NextRequest) { const provider = request.nextUrl.searchParams.get("provider") const from = request.nextUrl.searchParams.get("from") - if ( - !provider || - !VALID_PROVIDERS.includes(provider as Provider) - ) { + if (!provider || !VALID_PROVIDERS.includes(provider as Provider)) { return NextResponse.redirect( new URL("/login?error=invalid_provider", request.url) ) } - const workos = getWorkOSClient() - if (!workos) { - return NextResponse.redirect( - new URL("/dashboard", request.url) - ) + // check if workos is configured (dev mode fallback) + const isConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (!isConfigured) { + return NextResponse.redirect(new URL("/dashboard", request.url)) } - const authorizationUrl = - workos.userManagement.getAuthorizationUrl({ - provider: provider as Provider, - clientId: process.env.WORKOS_CLIENT_ID!, - redirectUri: process.env.WORKOS_REDIRECT_URI!, - state: from || "/dashboard", - }) + const workos = getWorkOS() + + const authorizationUrl = workos.userManagement.getAuthorizationUrl({ + provider: provider as Provider, + clientId: process.env.WORKOS_CLIENT_ID!, + redirectUri: process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI!, + state: from || "/dashboard", + }) return NextResponse.redirect(authorizationUrl) } diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts index 595f8c4..bbcbce4 100755 --- a/src/app/api/auth/verify-email/route.ts +++ b/src/app/api/auth/verify-email/route.ts @@ -1,32 +1,65 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client"; +import { NextRequest, NextResponse } from "next/server" +import { getWorkOS } from "@workos-inc/authkit-nextjs" +import { z } from "zod" + +const verifyEmailSchema = z.object({ + code: z.string().min(1, "Verification code is required"), + userId: z.string().min(1, "User ID is required"), +}) + +function mapWorkOSError(error: unknown): string { + const err = error as { code?: string; message?: string } + switch (err.code) { + case "invalid_code": + return "Invalid verification code. Please try again." + case "expired_code": + return "Code expired. Please request a new one." + default: + return err.message || "An error occurred. Please try again." + } +} export async function POST(request: NextRequest) { try { - const workos = getWorkOSClient(); - const { code, userId } = (await request.json()) as { - code: string - userId: string - }; + // validate input + const body = await request.json() + const parseResult = verifyEmailSchema.safeParse(body) - if (!workos) { + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + return NextResponse.json( + { success: false, error: firstIssue?.message || "Invalid input" }, + { status: 400 } + ) + } + + const { code, userId } = parseResult.data + + // check if workos is configured + const isConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (!isConfigured) { return NextResponse.json({ success: true, message: "Email verified (dev mode)", - }); + }) } - await workos.userManagement.verifyEmail({ userId, code }); + const workos = getWorkOS() + await workos.userManagement.verifyEmail({ userId, code }) return NextResponse.json({ success: true, message: "Email verified successfully", - }); + }) } catch (error) { - console.error("Email verification error:", error); + console.error("Email verification error:", error) return NextResponse.json( { success: false, error: mapWorkOSError(error) }, { status: 500 } - ); + ) } } diff --git a/src/app/callback/route.ts b/src/app/callback/route.ts index 9ae2789..77ac64b 100755 --- a/src/app/callback/route.ts +++ b/src/app/callback/route.ts @@ -1,3 +1,16 @@ import { handleAuth } from "@workos-inc/authkit-nextjs" +import { ensureUserExists } from "@/lib/auth" -export const GET = handleAuth() +export const GET = handleAuth({ + returnPathname: "/dashboard", + onSuccess: async ({ user }) => { + // sync user to our database on successful auth + await ensureUserExists({ + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + profilePictureUrl: user.profilePictureUrl, + }) + }, +}) diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 7cf4214..185a491 100755 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -11,13 +11,18 @@ import { } from "@/components/ui/sidebar" import { getProjects } from "@/app/actions/projects" import { ProjectListProvider } from "@/components/project-list-provider" +import { getCurrentUser, toSidebarUser } from "@/lib/auth" export default async function DashboardLayout({ children, }: { - children: React.ReactNode + readonly children: React.ReactNode }) { - const projectList = await getProjects() + const [projectList, authUser] = await Promise.all([ + getProjects(), + getCurrentUser(), + ]) + const user = authUser ? toSidebarUser(authUser) : null return ( @@ -32,10 +37,10 @@ export default async function DashboardLayout({ } as React.CSSProperties } > - + - +
{children} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 59cfc88..7bae8b4 100755 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Sora, IBM_Plex_Mono, Playfair_Display } from "next/font/google"; +import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components"; import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; @@ -42,11 +43,13 @@ export default function RootLayout({ }>) { return ( - + + {children} - + + ); } diff --git a/src/components/account-modal.tsx b/src/components/account-modal.tsx index 99eaed2..c748b58 100755 --- a/src/components/account-modal.tsx +++ b/src/components/account-modal.tsx @@ -1,7 +1,8 @@ "use client" import * as React from "react" -import { IconCamera } from "@tabler/icons-react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" @@ -16,19 +17,107 @@ import { import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" +import { getInitials } from "@/lib/utils" +import { updateProfile, changePassword } from "@/app/actions/profile" +import type { SidebarUser } from "@/lib/auth" -export function AccountModal({ - open, - onOpenChange, -}: { - open: boolean - onOpenChange: (open: boolean) => void -}) { - const [name, setName] = React.useState("Martine Vogel") - const [email, setEmail] = React.useState("martine@compass.io") +type AccountModalProps = { + readonly open: boolean + readonly onOpenChange: (open: boolean) => void + readonly user: SidebarUser | null +} + +export function AccountModal({ open, onOpenChange, user }: AccountModalProps) { + const router = useRouter() + + // Profile form state + const [firstName, setFirstName] = React.useState("") + const [lastName, setLastName] = React.useState("") + const [isSavingProfile, setIsSavingProfile] = React.useState(false) + + // Password form state const [currentPassword, setCurrentPassword] = React.useState("") const [newPassword, setNewPassword] = React.useState("") const [confirmPassword, setConfirmPassword] = React.useState("") + const [isChangingPassword, setIsChangingPassword] = React.useState(false) + + // Initialize form when user changes or modal opens + React.useEffect(() => { + if (user && open) { + setFirstName(user.firstName ?? "") + setLastName(user.lastName ?? "") + // Clear password fields when opening + setCurrentPassword("") + setNewPassword("") + setConfirmPassword("") + } + }, [user, open]) + + if (!user) { + return null + } + + const initials = getInitials(user.name) + + async function handleSaveProfile() { + setIsSavingProfile(true) + try { + const result = await updateProfile({ firstName, lastName }) + if (result.success) { + toast.success("Profile updated") + router.refresh() // Refresh to show updated data + onOpenChange(false) + } else { + toast.error(result.error) + } + } catch { + toast.error("Failed to update profile") + } finally { + setIsSavingProfile(false) + } + } + + async function handleChangePassword() { + // Client-side validation + if (newPassword !== confirmPassword) { + toast.error("Passwords do not match") + return + } + + if (newPassword.length < 8) { + toast.error("Password must be at least 8 characters") + return + } + + setIsChangingPassword(true) + try { + const result = await changePassword({ + currentPassword, + newPassword, + confirmPassword, + }) + if (result.success) { + toast.success("Password updated") + setCurrentPassword("") + setNewPassword("") + setConfirmPassword("") + } else { + toast.error(result.error) + } + } catch { + toast.error("Failed to change password") + } finally { + setIsChangingPassword(false) + } + } + + const hasProfileChanges = + firstName !== (user.firstName ?? "") || lastName !== (user.lastName ?? "") + + const canChangePassword = + currentPassword.length > 0 && + newPassword.length >= 8 && + confirmPassword.length > 0 return ( @@ -42,18 +131,13 @@ export function AccountModal({
-
- - - MV - - -
+ + {user.avatar && } + {initials} +
-

{name}

-

{email}

+

{user.name}

+

{user.email}

@@ -61,24 +145,41 @@ export function AccountModal({

Profile

-
- - setName(e.target.value)} - className="h-9" - /> +
+
+ + setFirstName(e.target.value)} + className="h-9" + disabled={isSavingProfile} + /> +
+
+ + setLastName(e.target.value)} + className="h-9" + disabled={isSavingProfile} + /> +
setEmail(e.target.value)} - className="h-9" + value={user.email} + className="h-9 bg-muted text-muted-foreground" + disabled + readOnly /> +

+ Contact support to change your email address. +

@@ -95,6 +196,7 @@ export function AccountModal({ onChange={(e) => setCurrentPassword(e.target.value)} placeholder="Enter current password" className="h-9" + disabled={isChangingPassword} />
@@ -106,6 +208,7 @@ export function AccountModal({ onChange={(e) => setNewPassword(e.target.value)} placeholder="Enter new password" className="h-9" + disabled={isChangingPassword} />
@@ -117,17 +220,36 @@ export function AccountModal({ onChange={(e) => setConfirmPassword(e.target.value)} placeholder="Confirm new password" className="h-9" + disabled={isChangingPassword} />
+
- - diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index ab21542..f1ede59 100755 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -23,6 +23,7 @@ import { NavProjects } from "@/components/nav-projects" import { NavUser } from "@/components/nav-user" import { useCommandMenu } from "@/components/command-menu-provider" import { useSettings } from "@/components/settings-provider" +import type { SidebarUser } from "@/lib/auth" import { Sidebar, SidebarContent, @@ -35,11 +36,6 @@ import { } from "@/components/ui/sidebar" const data = { - user: { - name: "Martine Vogel", - email: "martine@compass.io", - avatar: "/avatars/martine.jpg", - }, navMain: [ { title: "Compass", @@ -155,9 +151,11 @@ function SidebarNav({ export function AppSidebar({ projects = [], + user, ...props }: React.ComponentProps & { - projects?: { id: string; name: string }[] + readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }> + readonly user: SidebarUser | null }) { return ( @@ -190,10 +188,10 @@ export function AppSidebar({ - + - + ) diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx index 1a35cf4..06c1b53 100755 --- a/src/components/auth/login-form.tsx +++ b/src/components/auth/login-form.tsx @@ -1,40 +1,37 @@ -"use client"; +"use client" -import * as React from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { IconLoader } from "@tabler/icons-react"; -import { toast } from "sonner"; +import * as React from "react" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { IconLoader } from "@tabler/icons-react" +import { toast } from "sonner" -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/auth/password-input"; - -const loginSchema = z.object({ - email: z.string().email("Enter a valid email address"), - password: z.string().min(1, "Password is required"), -}); - -type LoginFormData = z.infer; +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { PasswordInput } from "@/components/auth/password-input" +import { loginSchema, type LoginInput } from "@/lib/validations/auth" export function LoginForm() { - const router = useRouter(); - const [isLoading, setIsLoading] = React.useState(false); + const router = useRouter() + const searchParams = useSearchParams() + const [isLoading, setIsLoading] = React.useState(false) + + // get the return URL from query params (set by middleware) + const returnTo = searchParams.get("from") || "/dashboard" const { register, handleSubmit, formState: { errors }, - } = useForm({ + } = useForm({ resolver: zodResolver(loginSchema), - }); + }) - const onSubmit = async (data: LoginFormData) => { - setIsLoading(true); + const onSubmit = async (data: LoginInput) => { + setIsLoading(true) try { const response = await fetch("/api/auth/login", { @@ -45,28 +42,27 @@ export function LoginForm() { email: data.email, password: data.password, }), - }); + }) const result = (await response.json()) as { - success: boolean; - message?: string; - error?: string; - redirectUrl?: string; - [key: string]: unknown; - }; + success: boolean + message?: string + error?: string + redirectUrl?: string + } if (result.success) { - toast.success("Welcome back!"); - router.push(result.redirectUrl as string); + toast.success("Welcome back!") + router.push(returnTo) } else { - toast.error(result.error || "Login failed"); + toast.error(result.error || "Login failed") } } catch { - toast.error("An error occurred. Please try again."); + toast.error("An error occurred. Please try again.") } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } return (
diff --git a/src/components/auth/password-input.tsx b/src/components/auth/password-input.tsx index bcfbbd1..41db480 100755 --- a/src/components/auth/password-input.tsx +++ b/src/components/auth/password-input.tsx @@ -8,32 +8,40 @@ import { Button } from "@/components/ui/button"; type PasswordInputProps = React.ComponentProps; export function PasswordInput({ className, ...props }: PasswordInputProps) { - const [showPassword, setShowPassword] = React.useState(false); + const [showPassword, setShowPassword] = React.useState(false); - return ( -
- - -
- ); + // Use props directly - the type system already constrains what can be passed + const safeProps = props; + + return ( +
+ + +
+ ); } diff --git a/src/components/auth/signup-form.tsx b/src/components/auth/signup-form.tsx index ddc5463..7c96196 100755 --- a/src/components/auth/signup-form.tsx +++ b/src/components/auth/signup-form.tsx @@ -1,53 +1,33 @@ -"use client"; +"use client" -import * as React from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { IconLoader } from "@tabler/icons-react"; -import { toast } from "sonner"; +import * as React from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { IconLoader } from "@tabler/icons-react" +import { toast } from "sonner" -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { PasswordInput } from "@/components/auth/password-input"; - -const signupSchema = z - .object({ - email: z.string().email("Enter a valid email address"), - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Must contain an uppercase letter") - .regex(/[a-z]/, "Must contain a lowercase letter") - .regex(/[0-9]/, "Must contain a number"), - confirmPassword: z.string(), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"], - }); - -type SignupFormData = z.infer; +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { PasswordInput } from "@/components/auth/password-input" +import { signupSchema, type SignupInput } from "@/lib/validations/auth" export function SignupForm() { - const router = useRouter(); - const [isLoading, setIsLoading] = React.useState(false); + const router = useRouter() + const [isLoading, setIsLoading] = React.useState(false) const { register, handleSubmit, formState: { errors }, - } = useForm({ + } = useForm({ resolver: zodResolver(signupSchema), - }); + }) - const onSubmit = async (data: SignupFormData) => { - setIsLoading(true); + const onSubmit = async (data: SignupInput) => { + setIsLoading(true) try { const response = await fetch("/api/auth/signup", { @@ -59,27 +39,26 @@ export function SignupForm() { firstName: data.firstName, lastName: data.lastName, }), - }); + }) const result = (await response.json()) as { - success: boolean; - message?: string; - error?: string; - [key: string]: unknown; - }; + success: boolean + message?: string + error?: string + } if (result.success) { - toast.success(result.message); - router.push("/verify-email?email=" + encodeURIComponent(data.email)); + toast.success(result.message || "Account created!") + router.push("/verify-email?email=" + encodeURIComponent(data.email)) } else { - toast.error(result.error || "Signup failed"); + toast.error(result.error || "Signup failed") } } catch { - toast.error("An error occurred. Please try again."); + toast.error("An error occurred. Please try again.") } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } return ( diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 3e0de3f..184c74a 100755 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -9,6 +9,8 @@ import { IconUserCircle, } from "@tabler/icons-react" +import { logout } from "@/app/actions/profile" + import { Avatar, AvatarFallback, @@ -30,19 +32,28 @@ import { useSidebar, } from "@/components/ui/sidebar" import { AccountModal } from "@/components/account-modal" +import { getInitials } from "@/lib/utils" +import type { SidebarUser } from "@/lib/auth" export function NavUser({ user, }: { - user: { - name: string - email: string - avatar: string - } + readonly user: SidebarUser | null }) { const { isMobile } = useSidebar() const [accountOpen, setAccountOpen] = React.useState(false) + // Don't render if no user (shouldn't happen in authenticated routes) + if (!user) { + return null + } + + const initials = getInitials(user.name) + + async function handleLogout() { + await logout() + } + return ( @@ -53,8 +64,8 @@ export function NavUser({ className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - - MV + {user.avatar && } + {initials}
{user.name} @@ -74,8 +85,8 @@ export function NavUser({
- - MV + {user.avatar && } + {initials}
{user.name} @@ -101,14 +112,14 @@ export function NavUser({ - + Log out - + ) } diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 124da02..216f156 100755 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -11,6 +11,8 @@ import { IconUserCircle, } from "@tabler/icons-react" +import { logout } from "@/app/actions/profile" + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { @@ -26,14 +28,26 @@ import { NotificationsPopover } from "@/components/notifications-popover" import { useCommandMenu } from "@/components/command-menu-provider" import { useFeedback } from "@/components/feedback-widget" import { AccountModal } from "@/components/account-modal" +import { getInitials } from "@/lib/utils" +import type { SidebarUser } from "@/lib/auth" -export function SiteHeader() { +export function SiteHeader({ + user, +}: { + readonly user: SidebarUser | null +}) { const { theme, setTheme } = useTheme() const { open: openCommand } = useCommandMenu() const { open: openFeedback } = useFeedback() const [accountOpen, setAccountOpen] = React.useState(false) const { toggleSidebar } = useSidebar() + const initials = user ? getInitials(user.name) : "?" + + async function handleLogout() { + await logout() + } + return (
{/* mobile header: single unified pill */} @@ -68,15 +82,15 @@ export function SiteHeader() { onClick={(e) => e.stopPropagation()} > - - MV + {user?.avatar && } + {initials} -

Martine Vogel

-

martine@compass.io

+

{user?.name ?? "User"}

+

{user?.email ?? ""}

setAccountOpen(true)}> @@ -89,7 +103,7 @@ export function SiteHeader() { Toggle theme - + Log out @@ -145,15 +159,15 @@ export function SiteHeader() { -

Martine Vogel

-

martine@compass.io

+

{user?.name ?? "User"}

+

{user?.email ?? ""}

setAccountOpen(true)}> @@ -161,7 +175,7 @@ export function SiteHeader() { Account - + Log out @@ -170,7 +184,7 @@ export function SiteHeader() {
- + ) } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 50cec1a..a5c7b8f 100755 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,34 +1,60 @@ -import { cookies } from "next/headers" -import { redirect } from "next/navigation" +import { withAuth, signOut } from "@workos-inc/authkit-nextjs" import { getCloudflareContext } from "@opennextjs/cloudflare" import { getDb } from "@/db" import { users } from "@/db/schema" import type { User } from "@/db/schema" import { eq } from "drizzle-orm" -import { SESSION_COOKIE, decodeJwtPayload } from "@/lib/session" export type AuthUser = { + readonly id: string + readonly email: string + readonly firstName: string | null + readonly lastName: string | null + readonly displayName: string | null + readonly avatarUrl: string | null + readonly role: string + readonly isActive: boolean + readonly lastLoginAt: string | null + readonly createdAt: string + readonly updatedAt: string +} + +/** + * User data for sidebar/header display components + */ +export type SidebarUser = Readonly<{ id: string + name: string email: string + avatar: string | null firstName: string | null lastName: string | null - displayName: string | null - avatarUrl: string | null - role: string - isActive: boolean - lastLoginAt: string | null - createdAt: string - updatedAt: string +}> + +/** + * Convert AuthUser to SidebarUser for UI components + */ +export function toSidebarUser(user: AuthUser): SidebarUser { + return { + id: user.id, + name: user.displayName ?? user.email.split("@")[0] ?? "User", + email: user.email, + avatar: user.avatarUrl, + firstName: user.firstName, + lastName: user.lastName, + } } export async function getCurrentUser(): Promise { try { + // check if workos is configured const isWorkOSConfigured = process.env.WORKOS_API_KEY && process.env.WORKOS_CLIENT_ID && !process.env.WORKOS_API_KEY.includes("placeholder") if (!isWorkOSConfigured) { + // return mock user for development return { id: "dev-user-1", email: "dev@compass.io", @@ -44,31 +70,34 @@ export async function getCurrentUser(): Promise { } } - const cookieStore = await cookies() - const token = cookieStore.get(SESSION_COOKIE)?.value - if (!token) return null + const session = await withAuth() + if (!session || !session.user) return null - const payload = decodeJwtPayload(token) - if (!payload?.sub) return null + const workosUser = session.user - const userId = payload.sub as string const { env } = await getCloudflareContext() if (!env?.DB) return null const db = getDb(env.DB) - const dbUser = await db + + // check if user exists in our database + let dbUser = await db .select() .from(users) - .where(eq(users.id, userId)) + .where(eq(users.id, workosUser.id)) .get() - if (!dbUser) return null + // if user doesn't exist, create them with default role + if (!dbUser) { + dbUser = await ensureUserExists(workosUser) + } + // update last login timestamp const now = new Date().toISOString() await db .update(users) .set({ lastLoginAt: now }) - .where(eq(users.id, userId)) + .where(eq(users.id, workosUser.id)) .run() return { @@ -98,10 +127,13 @@ export async function ensureUserExists(workosUser: { profilePictureUrl?: string | null }): Promise { const { env } = await getCloudflareContext() - if (!env?.DB) throw new Error("Database not available") + if (!env?.DB) { + throw new Error("Database not available") + } const db = getDb(env.DB) + // Check if user already exists const existing = await db .select() .from(users) @@ -111,6 +143,7 @@ export async function ensureUserExists(workosUser: { if (existing) return existing const now = new Date().toISOString() + const newUser = { id: workosUser.id, email: workosUser.email, @@ -121,7 +154,7 @@ export async function ensureUserExists(workosUser: { ? `${workosUser.firstName} ${workosUser.lastName}` : workosUser.email.split("@")[0], avatarUrl: workosUser.profilePictureUrl ?? null, - role: "office", + role: "office", // default role isActive: true, lastLoginAt: now, createdAt: now, @@ -129,21 +162,37 @@ export async function ensureUserExists(workosUser: { } await db.insert(users).values(newUser).run() + return newUser as User } export async function handleSignOut() { - const cookieStore = await cookies() - cookieStore.delete(SESSION_COOKIE) - redirect("/login") + await signOut() } export async function requireAuth(): Promise { const user = await getCurrentUser() - if (!user) throw new Error("Unauthorized") + if (!user) { + throw new Error("Unauthorized") + } return user } export async function requireEmailVerified(): Promise { - return requireAuth() + const user = await requireAuth() + + // check verification status + const isWorkOSConfigured = + process.env.WORKOS_API_KEY && + process.env.WORKOS_CLIENT_ID && + !process.env.WORKOS_API_KEY.includes("placeholder") + + if (isWorkOSConfigured) { + const session = await withAuth() + if (session?.user && !session.user.emailVerified) { + throw new Error("Email not verified") + } + } + + return user } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f2a5826..1e8e2df 100755 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -11,3 +11,19 @@ export function formatCurrency(amount: number): string { currency: "USD", }).format(amount) } + +/** + * Get initials from a name string (e.g., "John Doe" -> "JD") + * Returns up to 2 characters, uppercase + */ +export function getInitials(name: string): string { + return ( + name + .split(" ") + .filter(Boolean) + .map((part) => part[0]) + .join("") + .toUpperCase() + .slice(0, 2) || "?" + ) +} diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts new file mode 100755 index 0000000..3e182f1 --- /dev/null +++ b/src/lib/validations/auth.ts @@ -0,0 +1,93 @@ +import { z } from "zod" +import { emailSchema } from "./common" + +// --- Login --- + +export const loginSchema = z.object({ + email: emailSchema, + password: z.string().min(1, "Password is required"), +}) + +export type LoginInput = z.infer + +// --- Signup --- + +export const signupSchema = z + .object({ + email: emailSchema, + firstName: z + .string() + .min(1, "First name is required") + .max(50, "First name must be 50 characters or less"), + lastName: z + .string() + .min(1, "Last name is required") + .max(50, "Last name must be 50 characters or less"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }) + +export type SignupInput = z.infer + +// --- Password reset request --- + +export const passwordResetRequestSchema = z.object({ + email: emailSchema, +}) + +export type PasswordResetRequestInput = z.infer + +// --- Set new password --- + +export const setPasswordSchema = z + .object({ + token: z.string().min(1, "Reset token is required"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }) + +export type SetPasswordInput = z.infer + +// --- Email verification --- + +export const verifyEmailSchema = z.object({ + email: emailSchema, + code: z + .string() + .min(1, "Verification code is required") + .max(10, "Invalid verification code"), +}) + +export type VerifyEmailInput = z.infer + +// --- Passwordless (magic link) --- + +export const passwordlessSendSchema = z.object({ + email: emailSchema, +}) + +export const passwordlessVerifySchema = z.object({ + email: emailSchema, + code: z.string().min(1, "Code is required"), +}) + +export type PasswordlessSendInput = z.infer +export type PasswordlessVerifyInput = z.infer diff --git a/src/lib/validations/common.ts b/src/lib/validations/common.ts new file mode 100755 index 0000000..f9ada78 --- /dev/null +++ b/src/lib/validations/common.ts @@ -0,0 +1,74 @@ +import { z } from "zod" + +// --- Primitive schemas --- + +export const emailSchema = z + .string() + .min(1, "Email address is required") + .email("Please enter a valid email address") + +export const uuidSchema = z + .string() + .uuid("Invalid identifier format") + +export const nonEmptyString = z + .string() + .min(1, "This field is required") + +export const optionalString = z + .string() + .optional() + .transform((val) => val || undefined) + +// --- User roles --- + +export const userRoles = [ + "admin", + "executive", + "accounting", + "project_manager", + "coordinator", + "office", +] as const + +export type UserRole = (typeof userRoles)[number] + +export const userRoleSchema = z.enum(userRoles, { + message: "Please select a valid role", +}) + +// --- Pagination --- + +export const paginationSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}) + +// --- Date helpers --- + +export const dateStringSchema = z + .string() + .refine( + (val) => !Number.isNaN(Date.parse(val)), + "Please enter a valid date" + ) + +export const optionalDateSchema = z + .string() + .optional() + .refine( + (val) => !val || !Number.isNaN(Date.parse(val)), + "Please enter a valid date" + ) + +// --- Currency --- + +export const currencySchema = z + .number() + .nonnegative("Amount cannot be negative") + .multipleOf(0.01, "Amount must have at most 2 decimal places") + +export const positiveIntSchema = z + .number() + .int("Must be a whole number") + .positive("Must be greater than zero") diff --git a/src/lib/validations/financial.ts b/src/lib/validations/financial.ts new file mode 100755 index 0000000..d9bb164 --- /dev/null +++ b/src/lib/validations/financial.ts @@ -0,0 +1,171 @@ +import { z } from "zod" +import { uuidSchema, nonEmptyString, dateStringSchema, currencySchema } from "./common" + +// --- Customer --- + +export const createCustomerSchema = z.object({ + name: nonEmptyString.max(200, "Customer name must be 200 characters or less"), + email: z.string().email("Please enter a valid email address").optional(), + phone: z.string().max(50, "Phone number must be 50 characters or less").optional(), + address: z.string().max(500, "Address must be 500 characters or less").optional(), + netsuiteId: z.string().optional(), +}) + +export type CreateCustomerInput = z.infer + +export const updateCustomerSchema = z.object({ + id: uuidSchema, + name: nonEmptyString.max(200, "Customer name must be 200 characters or less").optional(), + email: z.string().email("Please enter a valid email address").optional(), + phone: z.string().max(50, "Phone number must be 50 characters or less").optional(), + address: z.string().max(500, "Address must be 500 characters or less").optional(), +}) + +export type UpdateCustomerInput = z.infer + +export const deleteCustomerSchema = z.object({ + id: uuidSchema, +}) + +export type DeleteCustomerInput = z.infer + +// --- Vendor --- + +export const createVendorSchema = z.object({ + name: nonEmptyString.max(200, "Vendor name must be 200 characters or less"), + email: z.string().email("Please enter a valid email address").optional(), + phone: z.string().max(50, "Phone number must be 50 characters or less").optional(), + address: z.string().max(500, "Address must be 500 characters or less").optional(), + netsuiteId: z.string().optional(), +}) + +export type CreateVendorInput = z.infer + +export const updateVendorSchema = z.object({ + id: uuidSchema, + name: nonEmptyString.max(200, "Vendor name must be 200 characters or less").optional(), + email: z.string().email("Please enter a valid email address").optional(), + phone: z.string().max(50, "Phone number must be 50 characters or less").optional(), + address: z.string().max(500, "Address must be 500 characters or less").optional(), +}) + +export type UpdateVendorInput = z.infer + +export const deleteVendorSchema = z.object({ + id: uuidSchema, +}) + +export type DeleteVendorInput = z.infer + +// --- Invoice --- + +export const createInvoiceSchema = z.object({ + customerId: uuidSchema, + projectId: uuidSchema.optional(), + invoiceNumber: nonEmptyString.max(50, "Invoice number must be 50 characters or less"), + amount: currencySchema, + dueDate: dateStringSchema, + description: z.string().max(1000, "Description must be 1000 characters or less").optional(), +}) + +export type CreateInvoiceInput = z.infer + +export const updateInvoiceSchema = z.object({ + id: uuidSchema, + amount: currencySchema.optional(), + dueDate: dateStringSchema.optional(), + description: z.string().max(1000, "Description must be 1000 characters or less").optional(), + status: z.enum(["draft", "sent", "paid", "overdue", "cancelled"]).optional(), +}) + +export type UpdateInvoiceInput = z.infer + +export const deleteInvoiceSchema = z.object({ + id: uuidSchema, +}) + +export type DeleteInvoiceInput = z.infer + +// --- Vendor Bill --- + +export const createVendorBillSchema = z.object({ + vendorId: uuidSchema, + projectId: uuidSchema.optional(), + billNumber: nonEmptyString.max(50, "Bill number must be 50 characters or less"), + amount: currencySchema, + dueDate: dateStringSchema, + description: z.string().max(1000, "Description must be 1000 characters or less").optional(), +}) + +export type CreateVendorBillInput = z.infer + +export const updateVendorBillSchema = z.object({ + id: uuidSchema, + amount: currencySchema.optional(), + dueDate: dateStringSchema.optional(), + description: z.string().max(1000, "Description must be 1000 characters or less").optional(), + status: z.enum(["pending", "approved", "paid", "cancelled"]).optional(), +}) + +export type UpdateVendorBillInput = z.infer + +export const deleteVendorBillSchema = z.object({ + id: uuidSchema, +}) + +export type DeleteVendorBillInput = z.infer + +// --- Payment --- + +export const createPaymentSchema = z.object({ + vendorBillId: uuidSchema.optional(), + invoiceId: uuidSchema.optional(), + amount: currencySchema, + paymentDate: dateStringSchema, + paymentMethod: z.enum(["check", "ach", "wire", "credit_card", "cash", "other"]).optional(), + referenceNumber: z.string().max(100, "Reference number must be 100 characters or less").optional(), +}) + +export type CreatePaymentInput = z.infer + +export const updatePaymentSchema = z.object({ + id: uuidSchema, + amount: currencySchema.optional(), + paymentDate: dateStringSchema.optional(), + paymentMethod: z.enum(["check", "ach", "wire", "credit_card", "cash", "other"]).optional(), + referenceNumber: z.string().max(100, "Reference number must be 100 characters or less").optional(), +}) + +export type UpdatePaymentInput = z.infer + +export const deletePaymentSchema = z.object({ + id: uuidSchema, +}) + +export type DeletePaymentInput = z.infer + +// --- Credit Memo --- + +export const createCreditMemoSchema = z.object({ + customerId: uuidSchema, + invoiceId: uuidSchema.optional(), + amount: currencySchema, + reason: z.string().max(500, "Reason must be 500 characters or less").optional(), +}) + +export type CreateCreditMemoInput = z.infer + +export const updateCreditMemoSchema = z.object({ + id: uuidSchema, + amount: currencySchema.optional(), + reason: z.string().max(500, "Reason must be 500 characters or less").optional(), + status: z.enum(["pending", "applied", "cancelled"]).optional(), +}) + +export type UpdateCreditMemoInput = z.infer + +export const deleteCreditMemoSchema = z.object({ + id: uuidSchema, +}) + +export type DeleteCreditMemoInput = z.infer diff --git a/src/lib/validations/index.ts b/src/lib/validations/index.ts new file mode 100755 index 0000000..8da851c --- /dev/null +++ b/src/lib/validations/index.ts @@ -0,0 +1,10 @@ +// Re-export all validation schemas for convenient imports +// Usage: import { loginSchema, createTaskSchema } from "@/lib/validations" + +export * from "./common" +export * from "./auth" +export * from "./users" +export * from "./teams" +export * from "./schedule" +export * from "./financial" +export * from "./profile" diff --git a/src/lib/validations/profile.ts b/src/lib/validations/profile.ts new file mode 100755 index 0000000..a47a4e8 --- /dev/null +++ b/src/lib/validations/profile.ts @@ -0,0 +1,38 @@ +import { z } from "zod" + +/** + * Schema for updating user profile (first name, last name) + */ +export const updateProfileSchema = z.object({ + firstName: z + .string() + .min(1, "First name is required") + .max(100, "First name must be 100 characters or less") + .trim(), + lastName: z + .string() + .min(1, "Last name is required") + .max(100, "Last name must be 100 characters or less") + .trim(), +}) + +export type UpdateProfileInput = z.infer + +/** + * Schema for changing password + */ +export const changePasswordSchema = z + .object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z + .string() + .min(8, "Password must be at least 8 characters") + .max(72, "Password must be 72 characters or less"), + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }) + +export type ChangePasswordInput = z.infer diff --git a/src/lib/validations/schedule.ts b/src/lib/validations/schedule.ts new file mode 100755 index 0000000..db850e8 --- /dev/null +++ b/src/lib/validations/schedule.ts @@ -0,0 +1,122 @@ +import { z } from "zod" +import { uuidSchema, nonEmptyString, dateStringSchema, positiveIntSchema } from "./common" + +// --- Task status --- + +export const taskStatuses = [ + "not_started", + "in_progress", + "completed", + "on_hold", + "cancelled", +] as const + +export type TaskStatus = (typeof taskStatuses)[number] + +export const taskStatusSchema = z.enum(taskStatuses, { + message: "Please select a valid task status", +}) + +// --- Create task --- + +export const createTaskSchema = z.object({ + projectId: uuidSchema, + name: nonEmptyString.max(200, "Task name must be 200 characters or less"), + description: z.string().max(2000, "Description must be 2000 characters or less").optional(), + startDate: dateStringSchema, + endDate: dateStringSchema, + status: taskStatusSchema.default("not_started"), + assigneeId: uuidSchema.optional(), + parentTaskId: uuidSchema.optional(), + sortOrder: z.number().int().nonnegative().optional(), +}).refine( + (data) => new Date(data.startDate) <= new Date(data.endDate), + { + message: "End date must be on or after start date", + path: ["endDate"], + } +) + +export type CreateTaskInput = z.infer + +// --- Update task --- + +export const updateTaskSchema = z.object({ + id: uuidSchema, + name: nonEmptyString.max(200, "Task name must be 200 characters or less").optional(), + description: z.string().max(2000, "Description must be 2000 characters or less").optional(), + startDate: dateStringSchema.optional(), + endDate: dateStringSchema.optional(), + status: taskStatusSchema.optional(), + assigneeId: uuidSchema.nullable().optional(), + parentTaskId: uuidSchema.nullable().optional(), + progress: z.number().int().min(0).max(100).optional(), + sortOrder: z.number().int().nonnegative().optional(), +}) + +export type UpdateTaskInput = z.infer + +// --- Delete task --- + +export const deleteTaskSchema = z.object({ + id: uuidSchema, +}) + +export type DeleteTaskInput = z.infer + +// --- Bulk update tasks --- + +export const bulkUpdateTasksSchema = z.object({ + projectId: uuidSchema, + tasks: z.array(z.object({ + id: uuidSchema, + startDate: dateStringSchema.optional(), + endDate: dateStringSchema.optional(), + sortOrder: z.number().int().nonnegative().optional(), + })), +}) + +export type BulkUpdateTasksInput = z.infer + +// --- Create baseline --- + +export const createBaselineSchema = z.object({ + projectId: uuidSchema, + name: nonEmptyString.max(100, "Baseline name must be 100 characters or less"), + description: z.string().max(500, "Description must be 500 characters or less").optional(), +}) + +export type CreateBaselineInput = z.infer + +// --- Delete baseline --- + +export const deleteBaselineSchema = z.object({ + id: uuidSchema, + projectId: uuidSchema, +}) + +export type DeleteBaselineInput = z.infer + +// --- Workday exception --- + +export const workdayExceptionTypes = ["holiday", "non_working", "working"] as const + +export type WorkdayExceptionType = (typeof workdayExceptionTypes)[number] + +export const createWorkdayExceptionSchema = z.object({ + projectId: uuidSchema, + date: dateStringSchema, + type: z.enum(workdayExceptionTypes, { + message: "Please select a valid exception type", + }), + name: nonEmptyString.max(100, "Name must be 100 characters or less").optional(), +}) + +export type CreateWorkdayExceptionInput = z.infer + +export const deleteWorkdayExceptionSchema = z.object({ + id: uuidSchema, + projectId: uuidSchema, +}) + +export type DeleteWorkdayExceptionInput = z.infer diff --git a/src/lib/validations/teams.ts b/src/lib/validations/teams.ts new file mode 100755 index 0000000..8e2cfb0 --- /dev/null +++ b/src/lib/validations/teams.ts @@ -0,0 +1,81 @@ +import { z } from "zod" +import { uuidSchema, nonEmptyString } from "./common" + +// --- Create team --- + +export const createTeamSchema = z.object({ + name: nonEmptyString.max(100, "Team name must be 100 characters or less"), + description: z.string().max(500, "Description must be 500 characters or less").optional(), +}) + +export type CreateTeamInput = z.infer + +// --- Update team --- + +export const updateTeamSchema = z.object({ + id: uuidSchema, + name: nonEmptyString.max(100, "Team name must be 100 characters or less").optional(), + description: z.string().max(500, "Description must be 500 characters or less").optional(), +}) + +export type UpdateTeamInput = z.infer + +// --- Delete team --- + +export const deleteTeamSchema = z.object({ + id: uuidSchema, +}) + +export type DeleteTeamInput = z.infer + +// --- Create group --- + +export const createGroupSchema = z.object({ + name: nonEmptyString.max(100, "Group name must be 100 characters or less"), + color: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/, "Color must be a valid hex code (e.g., #FF5733)") + .optional(), +}) + +export type CreateGroupInput = z.infer + +// --- Update group --- + +export const updateGroupSchema = z.object({ + id: uuidSchema, + name: nonEmptyString.max(100, "Group name must be 100 characters or less").optional(), + color: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/, "Color must be a valid hex code (e.g., #FF5733)") + .optional(), +}) + +export type UpdateGroupInput = z.infer + +// --- Delete group --- + +export const deleteGroupSchema = z.object({ + id: uuidSchema, +}) + +export type DeleteGroupInput = z.infer + +// --- Create organization --- + +export const createOrganizationSchema = z.object({ + name: nonEmptyString.max(200, "Organization name must be 200 characters or less"), + type: z.string().optional(), +}) + +export type CreateOrganizationInput = z.infer + +// --- Update organization --- + +export const updateOrganizationSchema = z.object({ + id: uuidSchema, + name: nonEmptyString.max(200, "Organization name must be 200 characters or less").optional(), + type: z.string().optional(), +}) + +export type UpdateOrganizationInput = z.infer diff --git a/src/lib/validations/users.ts b/src/lib/validations/users.ts new file mode 100755 index 0000000..b527a04 --- /dev/null +++ b/src/lib/validations/users.ts @@ -0,0 +1,75 @@ +import { z } from "zod" +import { emailSchema, uuidSchema, userRoleSchema, nonEmptyString } from "./common" + +// --- Update user role --- + +export const updateUserRoleSchema = z.object({ + userId: uuidSchema, + role: userRoleSchema, +}) + +export type UpdateUserRoleInput = z.infer + +// --- Deactivate user --- + +export const deactivateUserSchema = z.object({ + userId: uuidSchema, +}) + +export type DeactivateUserInput = z.infer + +// --- Invite user --- + +export const inviteUserSchema = z.object({ + email: emailSchema, + role: userRoleSchema, + organizationId: uuidSchema.optional(), +}) + +export type InviteUserInput = z.infer + +// --- Assign user to project --- + +export const assignUserToProjectSchema = z.object({ + userId: uuidSchema, + projectId: uuidSchema, + role: nonEmptyString, +}) + +export type AssignUserToProjectInput = z.infer + +// --- Assign user to team --- + +export const assignUserToTeamSchema = z.object({ + userId: uuidSchema, + teamId: uuidSchema, +}) + +export type AssignUserToTeamInput = z.infer + +// --- Assign user to group --- + +export const assignUserToGroupSchema = z.object({ + userId: uuidSchema, + groupId: uuidSchema, +}) + +export type AssignUserToGroupInput = z.infer + +// --- Remove user from team --- + +export const removeUserFromTeamSchema = z.object({ + userId: uuidSchema, + teamId: uuidSchema, +}) + +export type RemoveUserFromTeamInput = z.infer + +// --- Remove user from group --- + +export const removeUserFromGroupSchema = z.object({ + userId: uuidSchema, + groupId: uuidSchema, +}) + +export type RemoveUserFromGroupInput = z.infer diff --git a/src/lib/workos-client.ts b/src/lib/workos-client.ts deleted file mode 100755 index 72e465a..0000000 --- a/src/lib/workos-client.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { WorkOS } from "@workos-inc/node"; - -let workosClient: WorkOS | null = null; - -export function getWorkOSClient(): WorkOS | null { - const isConfigured = - process.env.WORKOS_API_KEY && - process.env.WORKOS_CLIENT_ID && - !process.env.WORKOS_API_KEY.includes("placeholder"); - - if (!isConfigured) { - return null; // dev mode - } - - if (!workosClient) { - workosClient = new WorkOS(process.env.WORKOS_API_KEY!); - } - - return workosClient; -} - -// error mapping helper -export function mapWorkOSError(error: unknown): string { - const err = error as { code?: string }; - if (err.code === "invalid_credentials") - return "Invalid email or password"; - if (err.code === "expired_code") return "Code expired. Request a new one."; - if (err.code === "user_exists") return "Email already registered"; - if (err.code === "invalid_code") return "Invalid code. Please try again."; - if (err.code === "user_not_found") return "No account found with this email"; - return "An error occurred. Please try again."; -} diff --git a/src/middleware.ts b/src/middleware.ts index b928306..13bf686 100755 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,53 +1,46 @@ -import { NextResponse } from "next/server" -import type { NextRequest } from "next/server" -import { SESSION_COOKIE, isTokenExpired } from "@/lib/session" +import { NextRequest } from "next/server" +import { authkit, handleAuthkitHeaders } from "@workos-inc/authkit-nextjs" -const isWorkOSConfigured = - process.env.WORKOS_API_KEY && - process.env.WORKOS_CLIENT_ID && - !process.env.WORKOS_API_KEY.includes("placeholder") +// public routes that don't require authentication +const publicPaths = [ + "/", + "/login", + "/signup", + "/reset-password", + "/verify-email", + "/invite", + "/callback", +] + +function isPublicPath(pathname: string): boolean { + // exact matches or starts with /api/auth/ + return ( + publicPaths.includes(pathname) || + pathname.startsWith("/api/auth/") || + pathname.startsWith("/api/netsuite/") + ) +} export default async function middleware(request: NextRequest) { const { pathname } = request.nextUrl - if (!isWorkOSConfigured) { - return NextResponse.next() + // get session and headers from authkit (handles token refresh automatically) + const { session, headers } = await authkit(request) + + // allow public paths + if (isPublicPath(pathname)) { + return handleAuthkitHeaders(request, headers) } - const publicRoutes = [ - "/login", - "/signup", - "/reset-password", - "/verify-email", - "/invite", - "/api/auth", - "/callback", - ] - - if ( - publicRoutes.some((route) => pathname.startsWith(route)) - ) { - return NextResponse.next() - } - - const token = request.cookies.get(SESSION_COOKIE)?.value - - if (!token || isTokenExpired(token)) { + // redirect unauthenticated users to our custom login page + if (!session.user) { const loginUrl = new URL("/login", request.url) loginUrl.searchParams.set("from", pathname) - const response = NextResponse.redirect(loginUrl) - if (token) response.cookies.delete(SESSION_COOKIE) - return response + return handleAuthkitHeaders(request, headers, { redirect: loginUrl.toString() }) } - const response = NextResponse.next() - response.headers.set("X-Frame-Options", "DENY") - response.headers.set("X-Content-Type-Options", "nosniff") - response.headers.set( - "Strict-Transport-Security", - "max-age=31536000; includeSubDomains" - ) - return response + // authenticated - continue with authkit headers + return handleAuthkitHeaders(request, headers) } export const config = {