diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f50a704 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Clerk (development keys) +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_replace_me +CLERK_SECRET_KEY=sk_test_replace_me +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up diff --git a/.gitignore b/.gitignore index 5ebf950..5bdbe1a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,17 @@ examples/target .env .env.local .env.production + +# Yarn +.pnp.* +.yarn/* + +# SQLite databases +*.db +*.db-journal +sqlite.db + +# CJS files +.tmp* +.tmp-2fa-test.cjs .DS_Store diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..911109d --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,7 @@ +enableGlobalCache: true + +enableScripts: true + +enableTelemetry: false + +pnpEnableInlining: false diff --git a/app/api/account/2fa/disable/route.ts b/app/api/account/2fa/disable/route.ts new file mode 100644 index 0000000..9a74192 --- /dev/null +++ b/app/api/account/2fa/disable/route.ts @@ -0,0 +1,32 @@ +import { + auth, + clerkClient, + reverificationErrorResponse, +} from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +export async function POST() { + const { userId, has } = await auth(); + + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!has({ reverification: "strict" })) { + return reverificationErrorResponse("strict"); + } + + try { + const client = await clerkClient(); + await client.users.disableUserMFA(userId); + + return NextResponse.json({ ok: true }); + } catch { + return NextResponse.json( + { error: "We couldn't disable two-factor authentication." }, + { status: 500 } + ); + } +} diff --git a/app/api/billing/cancel/route.ts b/app/api/billing/cancel/route.ts new file mode 100644 index 0000000..1685343 --- /dev/null +++ b/app/api/billing/cancel/route.ts @@ -0,0 +1,100 @@ +import { auth, clerkClient } from "@clerk/nextjs/server"; +import { isClerkAPIResponseError } from "@clerk/nextjs/errors"; +import { NextResponse } from "next/server"; + +import { isCancelablePaidSubscriptionItem } from "@/lib/workspace/plan"; + +export const runtime = "nodejs"; + +/** + * Cancels the active workspace subscription at the end of the current billing + * period. Clerk remains the source of truth; the UI re-reads the subscription + * after this call instead of keeping local billing state. + */ +export async function POST() { + const { userId, orgId, orgRole } = await auth(); + + if (!userId) { + return NextResponse.json( + { error: "Please sign in to continue." }, + { status: 401 } + ); + } + + if (!orgId) { + return NextResponse.json( + { error: "Choose a workspace before managing billing." }, + { status: 400 } + ); + } + + if (orgRole !== "org:admin" && orgRole !== "org:owner") { + return NextResponse.json( + { error: "Only workspace admins can cancel the subscription." }, + { status: 403 } + ); + } + + const client = await clerkClient(); + + let subscription; + + try { + subscription = await client.billing.getOrganizationBillingSubscription( + orgId + ); + } catch (error) { + if (isClerkAPIResponseError(error) && error.status === 404) { + return NextResponse.json( + { error: "This workspace does not have an active subscription." }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "We couldn't load this workspace's subscription." }, + { status: 502 } + ); + } + + const item = subscription.subscriptionItems.find( + (subscriptionItem) => + isCancelablePaidSubscriptionItem(subscriptionItem) && + !subscriptionItem.canceledAt + ); + + if (!item) { + return NextResponse.json( + { + error: + "This workspace has no paid subscription renewal to cancel. It may already be set to end.", + }, + { status: 409 } + ); + } + + try { + const cancelled = await client.billing.cancelSubscriptionItem(item.id, { + endNow: false, + }); + + return NextResponse.json({ + status: "non-renewing", + endsAt: cancelled.periodEnd + ? new Date(cancelled.periodEnd).toISOString() + : null, + }); + } catch (error) { + if (isClerkAPIResponseError(error)) { + return NextResponse.json( + { error: error.message }, + { status: error.status ?? 502 } + ); + } + + return NextResponse.json( + { error: "We couldn't cancel this subscription. Try again." }, + { status: 502 } + ); + } +} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 0bc80b0..33917d5 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -32,7 +32,7 @@ export default async function Page({ return ( <> -
+