Compare commits

...

3 Commits

Author SHA1 Message Date
Henry
4f4f507b58 fix: toggle spacing 2026-04-17 22:19:41 +01:00
Henry
d839195ad2 refactor: clean up 2026-04-17 22:07:55 +01:00
Henry
9815b5f273 feat(cloud): enable upgrading new and existing workspaces onto team/pro 2026-04-17 21:45:04 +01:00
10 changed files with 128 additions and 149 deletions

View File

@@ -207,7 +207,7 @@ export function LabelForm({
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isEdit && ( {!isEdit && (
<Toggle <Toggle
label={t`Create another`} label={t`Create another`}

View File

@@ -114,42 +114,6 @@ export function NewWorkspaceForm() {
weekStartDay: 1, weekStartDay: 1,
}); });
// If in cloud and Pro toggle is enabled, create checkout session for pro
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
try {
const response = await fetch(
"/api/stripe/create_checkout_session",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
slug: slug || undefined,
workspacePublicId: values.publicId,
cancelUrl: window.location.pathname + window.location.search,
successUrl: "/boards",
}),
},
);
const data = await response.json();
const url = (data as { url: string }).url;
if (url) {
window.location.href = url;
return; // Don't close modal if redirecting to checkout
}
} catch (error) {
console.error("Error creating checkout session:", error);
showPopup({
header: t`Error upgrading to Pro`,
message: t`Workspace created successfully. You can upgrade later in settings.`,
icon: "warning",
});
}
}
closeModal(); closeModal();
} }
}, },
@@ -361,7 +325,7 @@ export function NewWorkspaceForm() {
</div> </div>
<div <div
className={twMerge( className={twMerge(
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600", "mt-6 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
!showProBenefits && "mt-12", !showProBenefits && "mt-12",
)} )}
> >

View File

@@ -20,13 +20,13 @@ const workspaceSlugSchema = z
interface CheckoutSessionRequest { interface CheckoutSessionRequest {
successUrl: string; successUrl: string;
cancelUrl: string; cancelUrl: string;
plan: "team" | "pro";
billing?: string; billing?: string;
workspacePublicId?: string; workspacePublicId?: string;
slug?: string; slug?: string;
workspaceName?: string; workspaceName?: string;
workspaceDescription?: string; workspaceDescription?: string;
workspaceSlug?: string; workspaceSlug?: string;
plan?: string;
} }
export default withRateLimit( export default withRateLimit(
@@ -38,117 +38,114 @@ export default withRateLimit(
return res.status(405).json({ error: "Method not allowed" }); return res.status(405).json({ error: "Method not allowed" });
} }
try { const { user, db } = await createNextApiContext(req);
const { user, db } = await createNextApiContext(req);
if (!user) { if (!user) {
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
}
const body = req.body as CheckoutSessionRequest;
const {
successUrl,
cancelUrl,
plan,
billing,
workspacePublicId,
slug,
workspaceName,
workspaceDescription,
workspaceSlug,
} = body;
if (!successUrl || !cancelUrl || !plan) {
return res.status(400).json({ error: "Missing required fields" });
}
if (!workspacePublicId && !workspaceName) {
return res
.status(400)
.json({ error: "Must provide workspacePublicId or workspaceName" });
}
const resolvedSlug = slug ?? workspaceSlug;
if (resolvedSlug) {
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
if (!slugResult.success) {
return res.status(400).json({ error: "Invalid workspace slug" });
} }
}
const body = req.body as CheckoutSessionRequest; let resolvedWorkspacePublicId = workspacePublicId;
const { let subscriptionId: number | undefined;
successUrl,
cancelUrl, if (workspacePublicId) {
billing, // Existing workspace upgrade
const workspace = await workspaceRepo.getByPublicId(
db,
workspacePublicId, workspacePublicId,
slug, );
workspaceName,
workspaceDescription,
workspaceSlug,
} = body;
if (!successUrl || !cancelUrl) { if (!workspace) {
return res.status(400).json({ error: "Missing required fields" }); return res.status(404).json({ error: "Workspace not found" });
} }
if (!workspacePublicId && !workspaceName) { try {
return res await assertPermission(db, user.id, workspace.id, "workspace:manage");
.status(400) } catch {
.json({ error: "Must provide workspacePublicId or workspaceName" }); return res.status(403).json({ error: "Unauthorized" });
} }
const resolvedSlug = slug ?? workspaceSlug; const subscription = await subscriptionRepo.create(db, {
plan: plan,
if (resolvedSlug) { referenceId: workspacePublicId,
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug); userId: user.id,
if (!slugResult.success) { stripeCustomerId: user.stripeCustomerId ?? "",
return res.status(400).json({ error: "Invalid workspace slug" }); status: "incomplete",
}
}
let resolvedWorkspacePublicId = workspacePublicId;
let subscriptionId: number | undefined;
if (workspacePublicId) {
// Existing workspace upgrade
const workspace = await workspaceRepo.getByPublicId(
db,
workspacePublicId,
);
if (!workspace) {
return res.status(404).json({ error: "Workspace not found" });
}
try {
await assertPermission(db, user.id, workspace.id, "workspace:manage");
} catch {
return res.status(403).json({ error: "Unauthorized" });
}
const subscription = await subscriptionRepo.create(db, {
plan: "pro",
referenceId: workspacePublicId,
userId: user.id,
stripeCustomerId: user.stripeCustomerId ?? "",
status: "incomplete",
});
subscriptionId = subscription?.id;
if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
} else {
resolvedWorkspacePublicId = generateUID();
}
const isTeam = body.plan === "team";
const annualPriceId = isTeam
? (process.env.STRIPE_TEAM_PLAN_ANNUAL_PRICE_ID ??
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
: (process.env.STRIPE_PRO_PLAN_ANNUAL_PRICE_ID ??
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
const monthlyPriceId = isTeam
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: resolvedWorkspacePublicId,
customer: user.stripeCustomerId ?? undefined,
metadata: {
workspacePublicId: resolvedWorkspacePublicId!,
userId: user.id,
userEmail: user.email ?? "",
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
...(workspaceName && { workspaceName }),
...(workspaceDescription && { workspaceDescription }),
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
plan: body.plan ?? "pro",
isNewWorkspace: workspaceName ? "true" : "false",
},
}); });
return res.status(200).json({ url: session.url }); subscriptionId = subscription?.id;
} catch (error) {
return res.status(500).json({ error: "Error creating checkout session" }); if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
} else {
resolvedWorkspacePublicId = generateUID();
} }
const isTeam = body.plan === "team";
const annualPriceId = isTeam
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
const monthlyPriceId = isTeam
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: resolvedWorkspacePublicId,
customer: user.stripeCustomerId ?? undefined,
metadata: {
workspacePublicId: resolvedWorkspacePublicId!,
userId: user.id,
userEmail: user.email ?? "",
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
...(workspaceName && { workspaceName }),
...(workspaceDescription && { workspaceDescription }),
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
plan: plan,
isNewWorkspace: workspaceName ? "true" : "false",
},
});
return res.status(200).json({ url: session.url });
}), }),
); );

View File

@@ -515,7 +515,7 @@ export function NewCardForm({
</div> </div>
</div> </div>
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-5 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle <Toggle
label={t`Create another`} label={t`Create another`}
isChecked={isCreateAnotherEnabled} isChecked={isCreateAnotherEnabled}

View File

@@ -136,7 +136,7 @@ export function NewListForm({
}} }}
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle <Toggle
label={t`Create another`} label={t`Create another`}
isChecked={isCreateAnotherEnabled} isChecked={isCreateAnotherEnabled}

View File

@@ -184,7 +184,7 @@ const SelectSource = ({
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div> <div>
<Button <Button
type="submit" type="submit"
@@ -310,7 +310,7 @@ const ImportGithub: React.FC = () => {
<form onSubmit={handleSubmitProjects(onSubmitProjects)}> <form onSubmit={handleSubmitProjects(onSubmitProjects)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div> <div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle <Toggle
label={t`Select all`} label={t`Select all`}
isChecked={!!isSelectAllEnabled} isChecked={!!isSelectAllEnabled}
@@ -459,7 +459,7 @@ const ImportTrello: React.FC = () => {
<form onSubmit={handleSubmitBoards(onSubmitBoards)}> <form onSubmit={handleSubmitBoards(onSubmitBoards)}>
<div className="h-[105px] overflow-auto px-5">{renderContent()}</div> <div className="h-[105px] overflow-auto px-5">{renderContent()}</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle <Toggle
label={t`Select all`} label={t`Select all`}
isChecked={!!isSelectAllEnabled} isChecked={!!isSelectAllEnabled}

View File

@@ -148,7 +148,7 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
showTemplates={showTemplates} showTemplates={showTemplates}
customTemplates={formattedTemplates ?? []} customTemplates={formattedTemplates ?? []}
/> />
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
{!isTemplate && ( {!isTemplate && (
<Toggle <Toggle
label={t`Use template`} label={t`Use template`}

View File

@@ -341,7 +341,7 @@ export function InviteMemberForm({
)} )}
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle <Toggle
label={ label={
isShareInviteLinkEnabled isShareInviteLinkEnabled

View File

@@ -24,6 +24,8 @@ export function UpgradeToProConfirmation({
body: JSON.stringify({ body: JSON.stringify({
...(entityId && { slug: entityId }), ...(entityId && { slug: entityId }),
workspacePublicId: workspacePublicId, workspacePublicId: workspacePublicId,
plan: "pro",
billing: "monthly",
cancelUrl: "/settings", cancelUrl: "/settings",
successUrl: "/settings", successUrl: "/settings",
}), }),

View File

@@ -20,8 +20,11 @@ export function withApiLogging(
const requestId = randomUUID(); const requestId = randomUUID();
const route = req.url?.split("?")[0] ?? "unknown"; const route = req.url?.split("?")[0] ?? "unknown";
const input = { const input = {
...(req.query && Object.keys(req.query).length > 0 && { query: req.query }), ...(req.query &&
...(req.body && typeof req.body === "object" && Object.keys(req.body).length > 0 && { body: req.body }), Object.keys(req.query).length > 0 && { query: req.query }),
...(req.body &&
typeof req.body === "object" &&
Object.keys(req.body).length > 0 && { body: req.body }),
}; };
let statusCode = 200; let statusCode = 200;
@@ -41,7 +44,16 @@ export function withApiLogging(
// unauthenticated or auth unavailable // unauthenticated or auth unavailable
} }
await handler(req, res); let handlerError: unknown;
try {
await handler(req, res);
} catch (err) {
handlerError = err;
statusCode = 500;
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
}
const duration = Date.now() - start; const duration = Date.now() - start;
const meta = { const meta = {
@@ -53,6 +65,10 @@ export function withApiLogging(
...(isCloud && email && { email }), ...(isCloud && email && { email }),
...(Object.keys(input).length > 0 && { input }), ...(Object.keys(input).length > 0 && { input }),
status: statusCode, status: statusCode,
...(handlerError instanceof Error && {
error: handlerError.message,
stack: handlerError.stack,
}),
}; };
if (statusCode < 400) { if (statusCode < 400) {