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:
parent
2985d23d17
commit
a0dd50f59b
@ -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
1
.gitignore
vendored
@ -26,3 +26,4 @@ dist/
|
||||
.playwright-mcp
|
||||
mobile-ui-references/
|
||||
.fuse_*
|
||||
tmp/
|
||||
|
||||
28
CLAUDE.md
28
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<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
71
docs/RATE-LIMITING.md
Executable 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.
|
||||
@ -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
133
src/app/actions/profile.ts
Executable 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()
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
})
|
||||
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
103
src/lib/auth.ts
103
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<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
|
||||
}
|
||||
|
||||
@ -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
93
src/lib/validations/auth.ts
Executable 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
74
src/lib/validations/common.ts
Executable 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
171
src/lib/validations/financial.ts
Executable 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
10
src/lib/validations/index.ts
Executable 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
38
src/lib/validations/profile.ts
Executable 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
122
src/lib/validations/schedule.ts
Executable 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
81
src/lib/validations/teams.ts
Executable 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
75
src/lib/validations/users.ts
Executable 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>
|
||||
@ -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.";
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user