Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions examples/webhooks/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules
.next
.env
.env.local
.git
.gitignore
README.md
drizzle
dist
22 changes: 22 additions & 0 deletions examples/webhooks/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Database
POSTGRES_URL=postgresql://postgres:postgres@localhost:54328/webhooks_saas

# Better Auth
BETTER_AUTH_SECRET=your-secret-key-min-32-chars-long
BETTER_AUTH_URL=http://localhost:3008
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3008

# Commet
COMMET_API_KEY=your_api_key_here

# Webhook signing secret.
# Local dev: printed by `commet listen localhost:3008/api/webhooks/commet`.
# Production: shown when you create the webhook endpoint in the Commet dashboard.
COMMET_WEBHOOK_SECRET=your_webhook_secret_here

# Welcome email (Resend). Optional in local dev: without it the welcome email is skipped.
RESEND_API_KEY=
EMAIL_FROM=Acme <hello@yourdomain.com>

# App
NEXT_PUBLIC_APP_URL=http://localhost:3008
42 changes: 42 additions & 0 deletions examples/webhooks/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
pnpm-lock.yaml

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local
.env

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# Database
drizzle/
.commet/
150 changes: 150 additions & 0 deletions examples/webhooks/actions/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"use server";

import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/auth";
import { getUser } from "@/lib/auth/session";
import { hasUsableSubscription } from "@/lib/billing/entitlements";
import { db } from "@/lib/db/drizzle";
import { getBillingStateForUser } from "@/lib/db/queries";
import {
ActivityType,
activityLogs,
type NewActivityLog,
user,
} from "@/lib/db/schema";
import { safeRedirectPath } from "@/lib/redirects";
import {
updateAccountSchema,
updatePasswordSchema,
} from "@/lib/validations/auth";

type ActionState = {
error?: string;
success?: string;
fieldErrors?: Record<string, string[] | undefined>;
[key: string]:
| string
| string[]
| Record<string, string[] | undefined>
| undefined;
};

async function logActivity(
userId: string,
type: ActivityType,
ipAddress?: string,
) {
const newActivity: NewActivityLog = {
userId,
action: type,
ipAddress,
};
await db.insert(activityLogs).values(newActivity);
}

export async function getPostAuthRedirect(fallback = "/dashboard") {
const redirectPath = safeRedirectPath(fallback);
const currentUser = await getUser();
if (!currentUser) {
return "/sign-in";
}

if (redirectPath !== "/dashboard") {
return redirectPath;
}

const billing = await getBillingStateForUser(currentUser.id);
return hasUsableSubscription(billing?.subscriptionStatus)
? "/dashboard"
: "/pricing";
}

export async function signOut() {
const currentUser = await getUser();
if (currentUser) {
await logActivity(currentUser.id, ActivityType.SIGN_OUT);
}
redirect("/sign-in");
}

export async function updateAccount(
_prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
const currentUser = await getUser();
if (!currentUser) {
return { error: "Not authenticated" };
}

const rawData = {
name: formData.get("name") as string,
email: formData.get("email") as string,
};

const result = updateAccountSchema.safeParse(rawData);
if (!result.success) {
return {
error: "Validation failed",
fieldErrors: result.error.flatten().fieldErrors,
};
}

const { name, email } = result.data;

await db
.update(user)
.set({ name, email, updatedAt: new Date() })
.where(eq(user.id, currentUser.id));

await logActivity(currentUser.id, ActivityType.UPDATE_ACCOUNT);

return { success: "Account updated successfully." };
}

export async function updatePassword(
_prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
const currentUser = await getUser();
if (!currentUser) {
return { error: "Not authenticated" };
}

const rawData = {
currentPassword: formData.get("currentPassword") as string,
newPassword: formData.get("newPassword") as string,
confirmPassword: formData.get("confirmPassword") as string,
};

const result = updatePasswordSchema.safeParse(rawData);
if (!result.success) {
return {
error: "Validation failed",
fieldErrors: result.error.flatten().fieldErrors,
};
}

const { currentPassword, newPassword } = result.data;

try {
await auth.api.changePassword({
body: {
currentPassword,
newPassword,
revokeOtherSessions: false,
},
headers: await headers(),
});

await logActivity(currentUser.id, ActivityType.UPDATE_PASSWORD);

return { success: "Password updated successfully." };
} catch (error: unknown) {
if (error instanceof Error) {
return { error: error.message || "Failed to update password" };
}
return { error: "Failed to update password" };
}
}
34 changes: 34 additions & 0 deletions examples/webhooks/actions/plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use server";

import type { Plan } from "@commet/node";
import { unstable_cache } from "next/cache";
import { commet } from "@/lib/commet";

const getCachedPlans = unstable_cache(
async () => {
const result = await commet.plans.list();
if (!result.success || !result.data) {
throw new Error("Unable to load plans");
}
return result.data;
},
["plans"],
{ revalidate: 3600, tags: ["plans"] },
);

export async function getPlansAction(): Promise<{
success: boolean;
data?: Plan[];
error?: string;
}> {
try {
const plans = await getCachedPlans();
return {
success: true,
data: plans,
};
} catch (error) {
console.error("Error fetching plans:", error);
return { success: false, error: "Unable to load plans. Please try again." };
}
}
11 changes: 11 additions & 0 deletions examples/webhooks/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-dvh items-center justify-center p-4">
<div className="w-full max-w-sm">{children}</div>
</div>
);
}
122 changes: 122 additions & 0 deletions examples/webhooks/app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"use client";

import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { getPostAuthRedirect } from "@/actions/auth";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { signIn } from "@/lib/auth/auth-client";
import { handlePostSignupCheckout } from "@/lib/payments/actions";
import { normalizePlanCode } from "@/lib/plans";
import { buildInternalHref, safeRedirectPath } from "@/lib/redirects";

function SignInContent() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = safeRedirectPath(searchParams.get("redirect"));
const planCode = normalizePlanCode(searchParams.get("planCode"));
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError("");

const formData = new FormData(event.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;

const result = await signIn.email({ email, password });

if (result.error) {
setError(result.error.message || "Invalid credentials");
setLoading(false);
return;
}

if (planCode) {
const checkoutResult = await handlePostSignupCheckout(planCode);
if (checkoutResult.success && checkoutResult.checkoutUrl) {
window.location.href = checkoutResult.checkoutUrl;
return;
}
setError(
checkoutResult.error ||
"We couldn't continue with checkout. Please try again.",
);
setLoading(false);
return;
}

router.push(await getPostAuthRedirect(redirect));
}

const signUpHref = planCode
? buildInternalHref("/sign-up", {
planCode,
redirect: redirect !== "/dashboard" ? redirect : null,
})
: "/sign-up";

return (
<Card>
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Enter your credentials to continue.</CardDescription>
</CardHeader>
<CardContent>
{planCode && (
<div className="mb-4 flex flex-col rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-sm">
<span className="font-medium">Selected plan</span>
<span className="text-muted-foreground">
Sign in to continue with {planCode}.
</span>
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" required />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required />
</div>
{error && (
<p className="text-sm text-destructive-foreground">{error}</p>
)}
<Button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</Button>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link
href={signUpHref}
className="text-foreground underline underline-offset-4"
>
Sign up
</Link>
</p>
</form>
</CardContent>
</Card>
);
}

export default function SignInPage() {
return (
<Suspense>
<SignInContent />
</Suspense>
);
}
Loading