- );
+ )
}
diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx
index 747c559..ebd9357 100755
--- a/src/app/(auth)/signup/page.tsx
+++ b/src/app/(auth)/signup/page.tsx
@@ -1,8 +1,13 @@
-import { SignupForm } from "@/components/auth/signup-form";
+"use client"
+
+import { Suspense } from "react"
+import { SignupForm } from "@/components/auth/signup-form"
+import { SocialLoginButtons } from "@/components/auth/social-login-buttons"
+import { Separator } from "@/components/ui/separator"
export default function SignupPage() {
return (
-
+
Create an account
@@ -12,7 +17,22 @@ export default function SignupPage() {
+
+
+
+
+
+
+
+
+
+
+ or continue with email
+
+
+
+
- );
+ )
}
diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts
index 95449ba..bd928ce 100755
--- a/src/app/api/auth/callback/route.ts
+++ b/src/app/api/auth/callback/route.ts
@@ -1,3 +1,58 @@
-import { handleAuth } from "@workos-inc/authkit-nextjs";
+import { NextRequest, NextResponse } from "next/server"
+import { getWorkOSClient } from "@/lib/workos-client"
+import { ensureUserExists } from "@/lib/auth"
+import { SESSION_COOKIE } from "@/lib/session"
-export const GET = handleAuth();
+export async function GET(request: NextRequest) {
+ const code = request.nextUrl.searchParams.get("code")
+ const state = request.nextUrl.searchParams.get("state")
+
+ if (!code) {
+ return NextResponse.redirect(
+ new URL("/login?error=missing_code", request.url)
+ )
+ }
+
+ try {
+ const workos = getWorkOSClient()
+ if (!workos) {
+ return NextResponse.redirect(
+ new URL("/dashboard", request.url)
+ )
+ }
+
+ const result =
+ await workos.userManagement.authenticateWithCode({
+ code,
+ clientId: process.env.WORKOS_CLIENT_ID!,
+ })
+
+ await ensureUserExists({
+ id: result.user.id,
+ email: result.user.email,
+ firstName: result.user.firstName,
+ lastName: result.user.lastName,
+ profilePictureUrl: result.user.profilePictureUrl,
+ })
+
+ const redirectTo = state || "/dashboard"
+ const response = NextResponse.redirect(
+ new URL(redirectTo, request.url)
+ )
+
+ response.cookies.set(SESSION_COOKIE, result.accessToken, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 60 * 60 * 24 * 7,
+ })
+
+ return response
+ } catch (error) {
+ console.error("OAuth callback error:", error)
+ return NextResponse.redirect(
+ new URL("/login?error=auth_failed", request.url)
+ )
+ }
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 3f9fd7c..8ac4d62 100755
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -1,91 +1,110 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client";
+import { NextRequest, NextResponse } from "next/server"
+import { getWorkOSClient, mapWorkOSError } from "@/lib/workos-client"
+import { ensureUserExists } from "@/lib/auth"
+import { SESSION_COOKIE } from "@/lib/session"
export async function POST(request: NextRequest) {
try {
- const workos = getWorkOSClient();
+ const workos = getWorkOSClient()
const body = (await request.json()) as {
type: string
email: string
password?: string
code?: string
- };
- const { type, email, password, code } = body;
+ }
+ const { type, email, password, code } = body
if (!workos) {
return NextResponse.json({
success: true,
redirectUrl: "/dashboard",
devMode: true,
- });
+ })
}
if (type === "password") {
- const result = await workos.userManagement.authenticateWithPassword({
- email,
- password: password!,
- clientId: process.env.WORKOS_CLIENT_ID!,
- });
+ const result =
+ await workos.userManagement.authenticateWithPassword({
+ email,
+ password: password!,
+ clientId: process.env.WORKOS_CLIENT_ID!,
+ })
+
+ await ensureUserExists({
+ id: result.user.id,
+ email: result.user.email,
+ firstName: result.user.firstName,
+ lastName: result.user.lastName,
+ profilePictureUrl: result.user.profilePictureUrl,
+ })
const response = NextResponse.json({
success: true,
redirectUrl: "/dashboard",
- });
+ })
- response.cookies.set("wos-session", result.accessToken, {
+ 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 response
}
if (type === "passwordless_send") {
- const magicAuth = await workos.userManagement.createMagicAuth({
- email,
- });
+ const magicAuth =
+ await workos.userManagement.createMagicAuth({ email })
return NextResponse.json({
success: true,
magicAuthId: magicAuth.id,
- });
+ })
}
if (type === "passwordless_verify") {
- const result = await workos.userManagement.authenticateWithMagicAuth({
- code: code!,
- email,
- clientId: process.env.WORKOS_CLIENT_ID!,
- });
+ const result =
+ await workos.userManagement.authenticateWithMagicAuth({
+ code: code!,
+ email,
+ clientId: process.env.WORKOS_CLIENT_ID!,
+ })
+
+ await ensureUserExists({
+ id: result.user.id,
+ email: result.user.email,
+ firstName: result.user.firstName,
+ lastName: result.user.lastName,
+ profilePictureUrl: result.user.profilePictureUrl,
+ })
const response = NextResponse.json({
success: true,
redirectUrl: "/dashboard",
- });
+ })
- response.cookies.set("wos-session", result.accessToken, {
+ 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 response
}
return NextResponse.json(
{ success: false, error: "Invalid login type" },
{ status: 400 }
- );
+ )
} catch (error) {
- console.error("Login error:", error);
+ console.error("Login error:", error)
return NextResponse.json(
{ success: false, error: mapWorkOSError(error) },
{ status: 500 }
- );
+ )
}
}
diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts
new file mode 100755
index 0000000..3f8908d
--- /dev/null
+++ b/src/app/api/auth/sso/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getWorkOSClient } from "@/lib/workos-client"
+
+const VALID_PROVIDERS = [
+ "GoogleOAuth",
+ "MicrosoftOAuth",
+ "GitHubOAuth",
+ "AppleOAuth",
+] as const
+
+type Provider = (typeof VALID_PROVIDERS)[number]
+
+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)
+ ) {
+ return NextResponse.redirect(
+ new URL("/login?error=invalid_provider", request.url)
+ )
+ }
+
+ const workos = getWorkOSClient()
+ if (!workos) {
+ return NextResponse.redirect(
+ new URL("/dashboard", request.url)
+ )
+ }
+
+ const authorizationUrl =
+ workos.userManagement.getAuthorizationUrl({
+ provider: provider as Provider,
+ clientId: process.env.WORKOS_CLIENT_ID!,
+ redirectUri: process.env.WORKOS_REDIRECT_URI!,
+ state: from || "/dashboard",
+ })
+
+ return NextResponse.redirect(authorizationUrl)
+}
diff --git a/src/components/auth/social-login-buttons.tsx b/src/components/auth/social-login-buttons.tsx
new file mode 100755
index 0000000..5497b27
--- /dev/null
+++ b/src/components/auth/social-login-buttons.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import {
+ IconBrandGoogle,
+ IconBrandWindows,
+ IconBrandGithub,
+ IconBrandApple,
+} from "@tabler/icons-react"
+
+const providers = [
+ { id: "GoogleOAuth", label: "Google", icon: IconBrandGoogle },
+ {
+ id: "MicrosoftOAuth",
+ label: "Microsoft",
+ icon: IconBrandWindows,
+ },
+ { id: "GitHubOAuth", label: "GitHub", icon: IconBrandGithub },
+ { id: "AppleOAuth", label: "Apple", icon: IconBrandApple },
+] as const
+
+export function SocialLoginButtons() {
+ const searchParams = useSearchParams()
+ const from = searchParams.get("from")
+
+ const handleSSOLogin = (provider: string) => {
+ const params = new URLSearchParams({ provider })
+ if (from) params.set("from", from)
+ window.location.href = `/api/auth/sso?${params}`
+ }
+
+ return (
+
+ {providers.map(({ id, label, icon: Icon }) => (
+
+ ))}
+
+ )
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index b5420f4..50cec1a 100755
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,9 +1,11 @@
-import { withAuth, signOut } from "@workos-inc/authkit-nextjs"
+import { cookies } from "next/headers"
+import { redirect } from "next/navigation"
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 = {
id: string
@@ -21,14 +23,12 @@ export type AuthUser = {
export async function getCurrentUser(): Promise
{
try {
- // check if workos is configured
const isWorkOSConfigured =
process.env.WORKOS_API_KEY &&
process.env.WORKOS_CLIENT_ID &&
!process.env.WORKOS_API_KEY.includes("placeholder")
if (!isWorkOSConfigured) {
- // return mock user for development
return {
id: "dev-user-1",
email: "dev@compass.io",
@@ -44,34 +44,31 @@ export async function getCurrentUser(): Promise {
}
}
- const session = await withAuth()
- if (!session || !session.user) return null
+ const cookieStore = await cookies()
+ const token = cookieStore.get(SESSION_COOKIE)?.value
+ if (!token) return null
- const workosUser = session.user
+ const payload = decodeJwtPayload(token)
+ if (!payload?.sub) return null
+ const userId = payload.sub as string
const { env } = await getCloudflareContext()
if (!env?.DB) return null
const db = getDb(env.DB)
-
- // check if user exists in our database
- let dbUser = await db
+ const dbUser = await db
.select()
.from(users)
- .where(eq(users.id, workosUser.id))
+ .where(eq(users.id, userId))
.get()
- // if user doesn't exist, create them with default role
- if (!dbUser) {
- dbUser = await ensureUserExists(workosUser)
- }
+ if (!dbUser) return null
- // update last login timestamp
const now = new Date().toISOString()
await db
.update(users)
.set({ lastLoginAt: now })
- .where(eq(users.id, workosUser.id))
+ .where(eq(users.id, userId))
.run()
return {
@@ -93,7 +90,7 @@ export async function getCurrentUser(): Promise {
}
}
-async function ensureUserExists(workosUser: {
+export async function ensureUserExists(workosUser: {
id: string
email: string
firstName?: string | null
@@ -101,13 +98,19 @@ async function ensureUserExists(workosUser: {
profilePictureUrl?: string | null
}): Promise {
const { env } = await getCloudflareContext()
- if (!env?.DB) {
- throw new Error("Database not available")
- }
+ if (!env?.DB) throw new Error("Database not available")
const db = getDb(env.DB)
- const now = new Date().toISOString()
+ const existing = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, workosUser.id))
+ .get()
+
+ if (existing) return existing
+
+ const now = new Date().toISOString()
const newUser = {
id: workosUser.id,
email: workosUser.email,
@@ -118,7 +121,7 @@ async function ensureUserExists(workosUser: {
? `${workosUser.firstName} ${workosUser.lastName}`
: workosUser.email.split("@")[0],
avatarUrl: workosUser.profilePictureUrl ?? null,
- role: "office", // default role
+ role: "office",
isActive: true,
lastLoginAt: now,
createdAt: now,
@@ -126,37 +129,21 @@ async function ensureUserExists(workosUser: {
}
await db.insert(users).values(newUser).run()
-
return newUser as User
}
export async function handleSignOut() {
- await signOut()
+ const cookieStore = await cookies()
+ cookieStore.delete(SESSION_COOKIE)
+ redirect("/login")
}
export async function requireAuth(): Promise {
const user = await getCurrentUser()
- if (!user) {
- throw new Error("Unauthorized")
- }
+ if (!user) throw new Error("Unauthorized")
return user
}
export async function requireEmailVerified(): Promise {
- 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
+ return requireAuth()
}
diff --git a/src/lib/session.ts b/src/lib/session.ts
new file mode 100755
index 0000000..362ebd3
--- /dev/null
+++ b/src/lib/session.ts
@@ -0,0 +1,24 @@
+export const SESSION_COOKIE = "wos-session"
+
+export function decodeJwtPayload(
+ token: string
+): Record | null {
+ try {
+ const parts = token.split(".")
+ if (parts.length !== 3) return null
+ const base64 = parts[1]
+ .replace(/-/g, "+")
+ .replace(/_/g, "/")
+ const padded =
+ base64 + "=".repeat((4 - (base64.length % 4)) % 4)
+ return JSON.parse(atob(padded))
+ } catch {
+ return null
+ }
+}
+
+export function isTokenExpired(token: string): boolean {
+ const payload = decodeJwtPayload(token)
+ if (!payload?.exp) return true
+ return (payload.exp as number) * 1000 < Date.now()
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 6d8e5aa..b928306 100755
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,22 +1,19 @@
-import { NextResponse } from "next/server";
-import type { NextRequest } from "next/server";
-import { withAuth } from "@workos-inc/authkit-nextjs";
+import { NextResponse } from "next/server"
+import type { NextRequest } from "next/server"
+import { SESSION_COOKIE, isTokenExpired } from "@/lib/session"
const isWorkOSConfigured =
process.env.WORKOS_API_KEY &&
process.env.WORKOS_CLIENT_ID &&
- process.env.WORKOS_COOKIE_PASSWORD &&
- !process.env.WORKOS_API_KEY.includes("placeholder");
+ !process.env.WORKOS_API_KEY.includes("placeholder")
export default async function middleware(request: NextRequest) {
- const { pathname } = request.nextUrl;
+ const { pathname } = request.nextUrl
- // bypass auth in dev mode
if (!isWorkOSConfigured) {
- return NextResponse.next();
+ return NextResponse.next()
}
- // public routes (no auth required)
const publicRoutes = [
"/login",
"/signup",
@@ -24,40 +21,37 @@ export default async function middleware(request: NextRequest) {
"/verify-email",
"/invite",
"/api/auth",
- ];
+ "/callback",
+ ]
- if (publicRoutes.some((route) => pathname.startsWith(route))) {
- return NextResponse.next();
+ if (
+ publicRoutes.some((route) => pathname.startsWith(route))
+ ) {
+ return NextResponse.next()
}
- // check session for protected routes
- try {
- const session = await withAuth();
- if (!session || !session.user) {
- const loginUrl = new URL("/login", request.url);
- loginUrl.searchParams.set("from", pathname);
- return NextResponse.redirect(loginUrl);
- }
- } catch {
- const loginUrl = new URL("/login", request.url);
- loginUrl.searchParams.set("from", pathname);
- return NextResponse.redirect(loginUrl);
+ const token = request.cookies.get(SESSION_COOKIE)?.value
+
+ if (!token || isTokenExpired(token)) {
+ 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
}
- // add security headers
- const response = NextResponse.next();
- response.headers.set("X-Frame-Options", "DENY");
- response.headers.set("X-Content-Type-Options", "nosniff");
+ 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;
+ )
+ return response
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
-};
+}