Nicholai a0dd50f59b
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>
2026-02-05 08:20:51 -07:00

160 lines
4.4 KiB
TypeScript
Executable File

import { NextRequest, NextResponse } from "next/server"
import { getWorkOS, saveSession } from "@workos-inc/authkit-nextjs"
import { z } from "zod"
import { ensureUserExists } from "@/lib/auth"
// 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 {
// validate input
const body = await request.json()
const parseResult = loginRequestSchema.safeParse(body)
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",
devMode: true,
})
}
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,
firstName: result.user.firstName,
lastName: result.user.lastName,
profilePictureUrl: result.user.profilePictureUrl,
})
// 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",
})
}
if (data.type === "passwordless_send") {
const magicAuth = await workos.userManagement.createMagicAuth({
email: data.email,
})
return NextResponse.json({
success: true,
magicAuthId: magicAuth.id,
})
}
if (data.type === "passwordless_verify") {
const result = await workos.userManagement.authenticateWithMagicAuth({
code: data.code,
email: data.email,
clientId: process.env.WORKOS_CLIENT_ID!,
})
// sync user to our database
await ensureUserExists({
id: result.user.id,
email: result.user.email,
firstName: result.user.firstName,
lastName: result.user.lastName,
profilePictureUrl: result.user.profilePictureUrl,
})
// 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",
})
}
return NextResponse.json(
{ success: false, error: "Invalid login type" },
{ status: 400 }
)
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ success: false, error: mapWorkOSError(error) },
{ status: 500 }
)
}
}