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 <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-05 08:20:51 -07:00 committed by GitHub
parent 2985d23d17
commit a0dd50f59b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1849 additions and 481 deletions

View File

@ -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

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ dist/
.playwright-mcp
mobile-ui-references/
.fuse_*
tmp/

1
AGENTS.md Symbolic link
View File

@ -0,0 +1 @@
CLAUDE.md

View File

@ -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<T>`,
`Readonly<Record<K, V>>`, deep readonly wrappers. write `DeepReadonly<T>`
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<T, E>` 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.

71
docs/RATE-LIMITING.md Executable file
View File

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

View File

@ -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 (
<div className="space-y-4">
<div className="space-y-1">
@ -53,3 +53,11 @@ export default function LoginPage() {
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={<div className="space-y-2 animate-pulse">Loading...</div>}>
<LoginContent />
</Suspense>
)
}

133
src/app/actions/profile.ts Executable file
View File

@ -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<T = undefined> =
| { 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<ActionResult> {
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<ActionResult> {
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<void> {
await signOut()
}

View File

@ -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()

View File

@ -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 }
);
)
}
}

View File

@ -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({
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(

View File

@ -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!,
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,
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(

View File

@ -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",
});
})
}
}

View File

@ -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 }
);
)
}
}

View File

@ -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 }
);
)
}
}

View File

@ -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,27 +14,28 @@ 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({
const workos = getWorkOS()
const authorizationUrl = workos.userManagement.getAuthorizationUrl({
provider: provider as Provider,
clientId: process.env.WORKOS_CLIENT_ID!,
redirectUri: process.env.WORKOS_REDIRECT_URI!,
redirectUri: process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI!,
state: from || "/dashboard",
})

View File

@ -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 }
);
)
}
}

View File

@ -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,
})
},
})

View File

@ -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 (
<SettingsProvider>
@ -32,10 +37,10 @@ export default async function DashboardLayout({
} as React.CSSProperties
}
>
<AppSidebar variant="inset" projects={projectList} />
<AppSidebar variant="inset" projects={projectList} user={user} />
<FeedbackWidget>
<SidebarInset className="overflow-hidden">
<SiteHeader />
<SiteHeader user={user} />
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden pb-14 md:pb-0">
<div className="@container/main flex flex-1 flex-col min-w-0">
{children}

View File

@ -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";
@ -43,9 +44,11 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${sora.variable} ${ibmPlexMono.variable} ${playfair.variable} font-sans antialiased`}>
<AuthKitProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthKitProvider>
</body>
</html>
);

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -42,18 +131,13 @@ export function AccountModal({
<div className="space-y-3 py-1">
<div className="flex items-center gap-3">
<div className="relative">
<Avatar className="size-12">
<AvatarImage src="/avatars/martine.jpg" alt={name} />
<AvatarFallback className="text-sm">MV</AvatarFallback>
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
<AvatarFallback className="text-sm">{initials}</AvatarFallback>
</Avatar>
<button className="bg-primary text-primary-foreground absolute -right-0.5 -bottom-0.5 flex size-5 items-center justify-center rounded-full">
<IconCamera className="size-3" />
</button>
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{name}</p>
<p className="text-muted-foreground text-xs truncate">{email}</p>
<p className="text-sm font-medium truncate">{user.name}</p>
<p className="text-muted-foreground text-xs truncate">{user.email}</p>
</div>
</div>
@ -61,24 +145,41 @@ export function AccountModal({
<div className="space-y-3">
<h4 className="text-xs font-semibold uppercase text-muted-foreground">Profile</h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="name" className="text-xs">Name</Label>
<Label htmlFor="firstName" className="text-xs">First Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="h-9"
disabled={isSavingProfile}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lastName" className="text-xs">Last Name</Label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="h-9"
disabled={isSavingProfile}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="email" className="text-xs">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-9"
value={user.email}
className="h-9 bg-muted text-muted-foreground"
disabled
readOnly
/>
<p className="text-xs text-muted-foreground">
Contact support to change your email address.
</p>
</div>
</div>
@ -95,6 +196,7 @@ export function AccountModal({
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
className="h-9"
disabled={isChangingPassword}
/>
</div>
<div className="space-y-1.5">
@ -106,6 +208,7 @@ export function AccountModal({
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
className="h-9"
disabled={isChangingPassword}
/>
</div>
<div className="space-y-1.5">
@ -117,17 +220,36 @@ export function AccountModal({
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
className="h-9"
disabled={isChangingPassword}
/>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleChangePassword}
disabled={!canChangePassword || isChangingPassword}
>
{isChangingPassword ? "Changing..." : "Change Password"}
</Button>
</div>
</div>
<DialogFooter className="gap-2 pt-2">
<Button variant="outline" onClick={() => onOpenChange(false)} className="flex-1 sm:flex-initial h-9 text-sm">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 sm:flex-initial h-9 text-sm"
disabled={isSavingProfile}
>
Cancel
</Button>
<Button onClick={() => onOpenChange(false)} className="flex-1 sm:flex-initial h-9 text-sm">
Save Changes
<Button
onClick={handleSaveProfile}
className="flex-1 sm:flex-initial h-9 text-sm"
disabled={!hasProfileChanges || isSavingProfile}
>
{isSavingProfile ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>

View File

@ -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<typeof Sidebar> & {
projects?: { id: string; name: string }[]
readonly projects?: ReadonlyArray<{ readonly id: string; readonly name: string }>
readonly user: SidebarUser | null
}) {
return (
<Sidebar collapsible="icon" {...props}>
@ -190,10 +188,10 @@ export function AppSidebar({
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarNav projects={projects} />
<SidebarNav projects={projects as { id: string; name: string }[]} />
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
<NavUser user={user} />
</SidebarFooter>
</Sidebar>
)

View File

@ -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<typeof loginSchema>;
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<LoginFormData>({
} = useForm<LoginInput>({
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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">

View File

@ -10,12 +10,19 @@ type PasswordInputProps = React.ComponentProps<typeof Input>;
export function PasswordInput({ className, ...props }: PasswordInputProps) {
const [showPassword, setShowPassword] = React.useState(false);
// Use props directly - the type system already constrains what can be passed
const safeProps = props;
return (
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
className={className}
{...props}
autoComplete="current-password"
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
{...safeProps}
/>
<Button
type="button"
@ -24,6 +31,7 @@ export function PasswordInput({ className, ...props }: PasswordInputProps) {
className="absolute right-1 top-1 size-7"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<IconEyeOff className="size-4" />

View File

@ -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<typeof signupSchema>;
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<SignupFormData>({
} = useForm<SignupInput>({
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 (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">

View File

@ -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 (
<SidebarMenu>
<SidebarMenuItem>
@ -53,8 +64,8 @@ export function NavUser({
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">MV</AvatarFallback>
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="text-sidebar-foreground truncate font-medium">{user.name}</span>
@ -74,8 +85,8 @@ export function NavUser({
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">MV</AvatarFallback>
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
@ -101,14 +112,14 @@ export function NavUser({
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownMenuItem onSelect={handleLogout}>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} />
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} user={user} />
</SidebarMenu>
)
}

View File

@ -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 (
<header className="sticky top-0 z-40 flex shrink-0 items-center bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
{/* mobile header: single unified pill */}
@ -68,15 +82,15 @@ export function SiteHeader() {
onClick={(e) => e.stopPropagation()}
>
<Avatar className="size-8 grayscale">
<AvatarImage src="/avatars/martine.jpg" alt="Martine Vogel" />
<AvatarFallback className="text-xs">MV</AvatarFallback>
{user?.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="font-normal">
<p className="text-sm font-medium">Martine Vogel</p>
<p className="text-muted-foreground text-xs">martine@compass.io</p>
<p className="text-sm font-medium">{user?.name ?? "User"}</p>
<p className="text-muted-foreground text-xs">{user?.email ?? ""}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setAccountOpen(true)}>
@ -89,7 +103,7 @@ export function SiteHeader() {
Toggle theme
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownMenuItem onSelect={handleLogout}>
<IconLogout />
Log out
</DropdownMenuItem>
@ -145,15 +159,15 @@ export function SiteHeader() {
<DropdownMenuTrigger asChild>
<button className="ml-1 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="size-7 grayscale">
<AvatarImage src="/avatars/martine.jpg" alt="Martine Vogel" />
<AvatarFallback className="text-xs">MV</AvatarFallback>
{user?.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="font-normal">
<p className="text-sm font-medium">Martine Vogel</p>
<p className="text-muted-foreground text-xs">martine@compass.io</p>
<p className="text-sm font-medium">{user?.name ?? "User"}</p>
<p className="text-muted-foreground text-xs">{user?.email ?? ""}</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setAccountOpen(true)}>
@ -161,7 +175,7 @@ export function SiteHeader() {
Account
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownMenuItem onSelect={handleLogout}>
<IconLogout />
Log out
</DropdownMenuItem>
@ -170,7 +184,7 @@ export function SiteHeader() {
</div>
</div>
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} />
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} user={user} />
</header>
)
}

View File

@ -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<AuthUser | null> {
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<AuthUser | null> {
}
}
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<User> {
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<AuthUser> {
const user = await getCurrentUser()
if (!user) throw new Error("Unauthorized")
if (!user) {
throw new Error("Unauthorized")
}
return user
}
export async function requireEmailVerified(): Promise<AuthUser> {
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
}

View File

@ -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) || "?"
)
}

93
src/lib/validations/auth.ts Executable file
View File

@ -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<typeof loginSchema>
// --- 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<typeof signupSchema>
// --- Password reset request ---
export const passwordResetRequestSchema = z.object({
email: emailSchema,
})
export type PasswordResetRequestInput = z.infer<typeof passwordResetRequestSchema>
// --- 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<typeof setPasswordSchema>
// --- 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<typeof verifyEmailSchema>
// --- 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<typeof passwordlessSendSchema>
export type PasswordlessVerifyInput = z.infer<typeof passwordlessVerifySchema>

74
src/lib/validations/common.ts Executable file
View File

@ -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")

171
src/lib/validations/financial.ts Executable file
View File

@ -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<typeof createCustomerSchema>
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<typeof updateCustomerSchema>
export const deleteCustomerSchema = z.object({
id: uuidSchema,
})
export type DeleteCustomerInput = z.infer<typeof deleteCustomerSchema>
// --- 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<typeof createVendorSchema>
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<typeof updateVendorSchema>
export const deleteVendorSchema = z.object({
id: uuidSchema,
})
export type DeleteVendorInput = z.infer<typeof deleteVendorSchema>
// --- 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<typeof createInvoiceSchema>
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<typeof updateInvoiceSchema>
export const deleteInvoiceSchema = z.object({
id: uuidSchema,
})
export type DeleteInvoiceInput = z.infer<typeof deleteInvoiceSchema>
// --- 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<typeof createVendorBillSchema>
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<typeof updateVendorBillSchema>
export const deleteVendorBillSchema = z.object({
id: uuidSchema,
})
export type DeleteVendorBillInput = z.infer<typeof deleteVendorBillSchema>
// --- 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<typeof createPaymentSchema>
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<typeof updatePaymentSchema>
export const deletePaymentSchema = z.object({
id: uuidSchema,
})
export type DeletePaymentInput = z.infer<typeof deletePaymentSchema>
// --- 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<typeof createCreditMemoSchema>
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<typeof updateCreditMemoSchema>
export const deleteCreditMemoSchema = z.object({
id: uuidSchema,
})
export type DeleteCreditMemoInput = z.infer<typeof deleteCreditMemoSchema>

10
src/lib/validations/index.ts Executable file
View File

@ -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"

38
src/lib/validations/profile.ts Executable file
View File

@ -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<typeof updateProfileSchema>
/**
* 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<typeof changePasswordSchema>

122
src/lib/validations/schedule.ts Executable file
View File

@ -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<typeof createTaskSchema>
// --- 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<typeof updateTaskSchema>
// --- Delete task ---
export const deleteTaskSchema = z.object({
id: uuidSchema,
})
export type DeleteTaskInput = z.infer<typeof deleteTaskSchema>
// --- 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<typeof bulkUpdateTasksSchema>
// --- 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<typeof createBaselineSchema>
// --- Delete baseline ---
export const deleteBaselineSchema = z.object({
id: uuidSchema,
projectId: uuidSchema,
})
export type DeleteBaselineInput = z.infer<typeof deleteBaselineSchema>
// --- 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<typeof createWorkdayExceptionSchema>
export const deleteWorkdayExceptionSchema = z.object({
id: uuidSchema,
projectId: uuidSchema,
})
export type DeleteWorkdayExceptionInput = z.infer<typeof deleteWorkdayExceptionSchema>

81
src/lib/validations/teams.ts Executable file
View File

@ -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<typeof createTeamSchema>
// --- 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<typeof updateTeamSchema>
// --- Delete team ---
export const deleteTeamSchema = z.object({
id: uuidSchema,
})
export type DeleteTeamInput = z.infer<typeof deleteTeamSchema>
// --- 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<typeof createGroupSchema>
// --- 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<typeof updateGroupSchema>
// --- Delete group ---
export const deleteGroupSchema = z.object({
id: uuidSchema,
})
export type DeleteGroupInput = z.infer<typeof deleteGroupSchema>
// --- 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<typeof createOrganizationSchema>
// --- 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<typeof updateOrganizationSchema>

75
src/lib/validations/users.ts Executable file
View File

@ -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<typeof updateUserRoleSchema>
// --- Deactivate user ---
export const deactivateUserSchema = z.object({
userId: uuidSchema,
})
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>
// --- Invite user ---
export const inviteUserSchema = z.object({
email: emailSchema,
role: userRoleSchema,
organizationId: uuidSchema.optional(),
})
export type InviteUserInput = z.infer<typeof inviteUserSchema>
// --- Assign user to project ---
export const assignUserToProjectSchema = z.object({
userId: uuidSchema,
projectId: uuidSchema,
role: nonEmptyString,
})
export type AssignUserToProjectInput = z.infer<typeof assignUserToProjectSchema>
// --- Assign user to team ---
export const assignUserToTeamSchema = z.object({
userId: uuidSchema,
teamId: uuidSchema,
})
export type AssignUserToTeamInput = z.infer<typeof assignUserToTeamSchema>
// --- Assign user to group ---
export const assignUserToGroupSchema = z.object({
userId: uuidSchema,
groupId: uuidSchema,
})
export type AssignUserToGroupInput = z.infer<typeof assignUserToGroupSchema>
// --- Remove user from team ---
export const removeUserFromTeamSchema = z.object({
userId: uuidSchema,
teamId: uuidSchema,
})
export type RemoveUserFromTeamInput = z.infer<typeof removeUserFromTeamSchema>
// --- Remove user from group ---
export const removeUserFromGroupSchema = z.object({
userId: uuidSchema,
groupId: uuidSchema,
})
export type RemoveUserFromGroupInput = z.infer<typeof removeUserFromGroupSchema>

View File

@ -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.";
}

View File

@ -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")
export default async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (!isWorkOSConfigured) {
return NextResponse.next()
}
const publicRoutes = [
// public routes that don't require authentication
const publicPaths = [
"/",
"/login",
"/signup",
"/reset-password",
"/verify-email",
"/invite",
"/api/auth",
"/callback",
]
]
if (
publicRoutes.some((route) => pathname.startsWith(route))
) {
return NextResponse.next()
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
// 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 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 = {