Compare commits

...

15 Commits

Author SHA1 Message Date
Henry
a31885e265 feat: add returnUrl params 2026-04-10 21:43:48 +01:00
Henry
5c5d208fc4 feat: set slug on workspace name change 2026-04-10 21:26:56 +01:00
Henry
cb76926041 feat: create workspace on successful activation 2026-04-10 15:28:27 +01:00
Henry
6dfee580f7 refactor: improve webhook logs 2026-04-05 23:09:01 +01:00
Henry
e20104a292 chore: update types 2026-04-05 23:01:21 +01:00
Henry
dbf53717db feat: create worspace on checkout.session.completed 2026-04-05 23:00:12 +01:00
Henry
0dc5092ebc feat: add logging to next api routes 2026-04-05 22:33:06 +01:00
Henry
e81f5d7fe6 feat: allow setting description when creating a workspace 2026-04-03 00:03:12 +01:00
Henry
f4bb7f8443 feat: add team to workspace plans 2026-04-03 00:02:39 +01:00
Henry
ac2d675934 refactor: tweak onboarding steps UX 2026-04-03 00:00:18 +01:00
Henry
edc9e1254e fix: validate/convert slug input 2026-04-02 13:06:22 +01:00
Henry
a713d09c46 feat: tweak padding in boards mockup 2026-04-01 23:46:35 +01:00
Henry
385bc0de61 refactor: optimize board rendering in workspace details 2026-04-01 23:41:13 +01:00
Henry
1e93d85318 feat: add workspace details step 2026-04-01 23:30:00 +01:00
Henry
abaf2badb0 feat: setup onboarding and select plan page 2026-04-01 21:26:12 +01:00
27 changed files with 5094 additions and 277 deletions

View File

@@ -1,4 +1,6 @@
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect, useRef, useState } from "react";
import {
TbLayoutSidebarLeftCollapse,
@@ -43,6 +45,7 @@ export default function Dashboard({
const { resolvedTheme } = useTheme();
const { openModal } = useModal();
const { availableWorkspaces, hasLoaded } = useWorkspace();
const router = useRouter();
const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
@@ -98,9 +101,13 @@ export default function Dashboard({
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
openModal("NEW_WORKSPACE", undefined, undefined, false);
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`);
} else {
openModal("NEW_WORKSPACE", undefined, undefined, false);
}
}
}, [hasLoaded, availableWorkspaces.length, openModal]);
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
const isDarkMode = resolvedTheme === "dark";

View File

@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
</button>
)}
{iconRight && type !== "password" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
{iconRight}
</div>
)}

View File

@@ -127,7 +127,7 @@ export function NewWorkspaceForm() {
body: JSON.stringify({
slug: slug || undefined,
workspacePublicId: values.publicId,
cancelUrl: "/settings/workspace?upgrade=pro",
cancelUrl: window.location.pathname + window.location.search,
successUrl: "/boards",
}),
},

View File

@@ -7,15 +7,17 @@ const Toggle = ({
label,
disabled,
showLabel = true,
labelPosition = "before",
}: {
isChecked: boolean;
onChange: () => void;
label: string;
disabled?: boolean;
showLabel?: boolean;
labelPosition?: "before" | "after";
}) => (
<div className="mr-4 flex items-center justify-end">
{showLabel && (
<div className="flex items-center">
{showLabel && labelPosition === "before" && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
@@ -38,6 +40,11 @@ const Toggle = ({
)}
/>
</Switch>
{showLabel && labelPosition === "after" && (
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
</div>
);

View File

@@ -1,5 +1,7 @@
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { Fragment, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
@@ -18,6 +20,7 @@ export default function WorkspaceMenu({
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
useWorkspace();
const { openModal } = useModal();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
@@ -147,7 +150,11 @@ export default function WorkspaceMenu({
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
<Menu.Item>
<button
onClick={() => openModal("NEW_WORKSPACE")}
onClick={() =>
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`)
: openModal("NEW_WORKSPACE")
}
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
{t`Create workspace`}

View File

@@ -1,12 +1,13 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
@@ -32,7 +33,9 @@ export default withRateLimit(
try {
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
} catch {
return res.status(500).json({ message: "Storage endpoint misconfigured" });
return res
.status(500)
.json({ message: "Storage endpoint misconfigured" });
}
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
@@ -66,10 +69,7 @@ export default withRateLimit(
const buffer = await upstream.arrayBuffer();
return res.send(Buffer.from(buffer));
} catch (error) {
console.error("Error downloading attachment:", error);
return res
.status(500)
.json({ message: "Failed to download attachment" });
return res.status(500).json({ message: "Failed to download attachment" });
}
},
}),
);

View File

@@ -1,25 +1,25 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
console.error("Error fetching OSS friends:", error);
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
},
try {
const response = await fetch("https://formbricks.com/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch from Formbricks");
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
return res.status(500).json({ message: "Failed to fetch OSS friends" });
}
}),
);

View File

@@ -2,34 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
import { createStripeClient } from "@kan/stripe";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { createStripeClient } from "@kan/stripe";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user } = await createNextApiContext(req);
if (!user?.stripeCustomerId) {
return res.status(404).json({ error: "No billing account found" });
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
try {
const { user } = await createNextApiContext(req);
return res.status(200).json({ url: session.url });
} catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating portal session" });
}
},
if (!user?.stripeCustomerId) {
return res.status(404).json({ error: "No billing account found" });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
});
return res.status(200).json({ url: session.url });
} catch (error) {
return res.status(500).json({ error: "Error creating portal session" });
}
}),
);

View File

@@ -3,10 +3,13 @@ import { env } from "next-runtime-env";
import { z } from "zod";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { assertPermission } from "@kan/api/utils/permissions";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createStripeClient } from "@kan/stripe";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z
.string()
@@ -17,103 +20,135 @@ const workspaceSlugSchema = z
interface CheckoutSessionRequest {
successUrl: string;
cancelUrl: string;
slug: string;
workspacePublicId: string;
stripeCustomerId: string;
billing?: string;
workspacePublicId?: string;
slug?: string;
workspaceName?: string;
workspaceDescription?: string;
workspaceSlug?: string;
plan?: string;
}
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient();
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { user, db } = await createNextApiContext(req);
if (!user) {
return res.status(404).json({ error: "User not found" });
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const body = req.body as CheckoutSessionRequest;
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
try {
const { user, db } = await createNextApiContext(req);
if (!successUrl || !cancelUrl || !workspacePublicId) {
return res.status(400).json({ error: "Missing required fields" });
}
if (slug) {
const slugResult = workspaceSlugSchema.safeParse(slug);
if (!slugResult.success) {
return new Response(
JSON.stringify({ error: "Invalid workspace slug" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
}
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
const isMemberOfWorkspace = workspace.some(
({ workspace }) => workspace.publicId === body.workspacePublicId,
);
if (!isMemberOfWorkspace) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
const subscription = await subscriptionRepo.create(db, {
plan: "pro",
referenceId: workspacePublicId,
userId: user.id,
stripeCustomerId: user.stripeCustomerId ?? "",
status: "incomplete",
});
const subscriptionId = subscription?.id;
if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" });
}
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [
{
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
quantity: 1,
},
],
subscription_data: {
trial_period_days: 14,
},
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: workspacePublicId,
customer: user.stripeCustomerId ?? undefined,
metadata: {
...(slug && { workspaceSlug: slug }),
const body = req.body as CheckoutSessionRequest;
const {
successUrl,
cancelUrl,
billing,
workspacePublicId,
userId: user.id,
subscriptionId,
},
});
slug,
workspaceName,
workspaceDescription,
workspaceSlug,
} = body;
return res.status(200).json({ url: session.url });
} catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating checkout session" });
}
},
if (!successUrl || !cancelUrl) {
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" });
}
}
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 });
} catch (error) {
return res.status(500).json({ error: "Error creating checkout session" });
}
}),
);

View File

@@ -1,12 +1,14 @@
import { randomUUID } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { createStripeClient } from "@kan/stripe";
const log = createLogger("stripe-webhook");
const log = createLogger("api");
async function buffer(readable: Readable) {
const chunks = [];
@@ -21,6 +23,9 @@ export default async function handler(
res: NextApiResponse,
) {
const stripe = createStripeClient();
const start = Date.now();
const requestId = randomUUID();
const procedure = req.url?.split("?")[0] ?? "/api/stripe/webhook";
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
@@ -44,29 +49,85 @@ export default async function handler(
const { db } = await createNextApiContext(req);
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
switch (event.type) {
case "checkout.session.completed": {
const checkoutSession = event.data.object;
const meta = checkoutSession.metadata;
const metaData = checkoutSession.metadata;
if (!meta?.workspacePublicId) break;
if (metaData?.workspacePublicId) {
await workspaceRepo.update(db, metaData.workspacePublicId, {
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
plan: "pro",
const plan = meta.plan === "team" ? "team" : ("pro" as const);
if (
meta.isNewWorkspace === "true" &&
meta.workspaceName &&
meta.userId &&
meta.userEmail
) {
const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId);
if (!existing) {
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
await workspaceRepo.create(db, {
publicId: meta.workspacePublicId,
name: meta.workspaceName,
slug,
plan,
createdBy: meta.userId,
createdByEmail: meta.userEmail,
...(meta.workspaceDescription && {
description: meta.workspaceDescription,
}),
});
await subscriptionRepo.create(db, {
plan,
referenceId: meta.workspacePublicId,
userId: meta.userId,
stripeCustomerId: checkoutSession.customer as string,
status: "active",
});
}
} else {
// Existing workspace upgrade — update plan (and slug for pro)
await workspaceRepo.update(db, meta.workspacePublicId, {
plan,
...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }),
});
}
break;
}
default:
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
}
log.info(
{
requestId,
procedure,
transport: "rest",
duration: Date.now() - start,
status: 200,
input: { eventType: event.type, eventId: event.id },
},
"API OK",
);
return res.status(200).json({ received: true });
} catch (err) {
log.error({ err }, "Stripe webhook handler failed");
log.error(
{
requestId,
procedure,
transport: "rest",
duration: Date.now() - start,
status: 400,
err,
},
"API error",
);
return res.status(400).json({ message: "Webhook handler failed" });
}
}

View File

@@ -1,52 +1,58 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { addYears } from "date-fns";
import { createNextApiContext } from "@kan/api/trpc";
import { integrations } from "@kan/db/schema";
import { addYears } from "date-fns";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { integrations } from "@kan/db/schema";
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const { user } = await createNextApiContext(req);
const { user } = await createNextApiContext(req);
if (!user)
return res.status(401).json({ message: "User not authenticated" });
if (!user)
return res.status(401).json({ message: "User not authenticated" });
const apiKey = process.env.TRELLO_APP_API_KEY;
const apiKey = process.env.TRELLO_APP_API_KEY;
if (!apiKey)
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
if (!apiKey)
return res
.status(500)
.json({ message: "Trello API key not set in Environment Variables" });
const token = req.body.token;
const token = req.body.token;
if (!token)
return res.status(400).json({ message: "No token found" });
if (!token) return res.status(400).json({ message: "No token found" });
try {
const { db } = await createNextApiContext(req);
try {
const { db } = await createNextApiContext(req);
await db.insert(integrations).values({
provider: "trello",
userId: user.id,
accessToken: token,
expiresAt: addYears(new Date(), 1),
}).onConflictDoUpdate({
set: {
accessToken: token,
expiresAt: addYears(new Date(), 1),
},
target: [integrations.userId, integrations.provider],
});
await db
.insert(integrations)
.values({
provider: "trello",
userId: user.id,
accessToken: token,
expiresAt: addYears(new Date(), 1),
})
.onConflictDoUpdate({
set: {
accessToken: token,
expiresAt: addYears(new Date(), 1),
},
target: [integrations.userId, integrations.provider],
});
return res.status(200).json({ message: "Trello authentication successful" });
} catch (err) {
console.error("Trello authentication error:", err);
return res.status(400).json({ message: "Trello authentication failed" });
}
},
);
return res
.status(200)
.json({ message: "Trello authentication successful" });
} catch (err) {
return res.status(400).json({ message: "Trello authentication failed" });
}
}),
);

View File

@@ -3,9 +3,11 @@ import { Novu } from "@novu/api";
import { jwtVerify } from "jose";
import { z } from "zod";
import { env } from "~/env";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import { env } from "~/env";
const requestSchema = z.object({
token: z.string().min(1),
});
@@ -22,84 +24,85 @@ const textEncoder = new TextEncoder();
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
error: "Unsubscribe endpoint is not available.",
code: "UNAVAILABLE",
});
}
withApiLogging(
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
error: "Unsubscribe endpoint is not available.",
code: "UNAVAILABLE",
});
}
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({
success: false,
error: "Method not allowed.",
code: "METHOD_NOT_ALLOWED",
});
}
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({
success: false,
error: "Method not allowed.",
code: "METHOD_NOT_ALLOWED",
});
}
const parsedBody = requestSchema.safeParse(req.body);
const parsedBody = requestSchema.safeParse(req.body);
if (!parsedBody.success) {
return res.status(400).json({
success: false,
error: "Invalid request payload.",
code: "BAD_REQUEST",
});
}
if (!parsedBody.success) {
return res.status(400).json({
success: false,
error: "Invalid request payload.",
code: "BAD_REQUEST",
});
}
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
return res.status(500).json({
success: false,
error: "Unsubscribe service is not configured.",
code: "NOT_CONFIGURED",
});
}
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
return res.status(500).json({
success: false,
error: "Unsubscribe service is not configured.",
code: "NOT_CONFIGURED",
});
}
let payload: z.infer<typeof tokenPayloadSchema>;
let payload: z.infer<typeof tokenPayloadSchema>;
try {
const verified = await jwtVerify(
parsedBody.data.token,
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
{
// We intentionally do not use exp/iat claims
// tokens are long-lived and validated only by signature + payload.
clockTolerance: "0s",
},
);
payload = tokenPayloadSchema.parse(verified.payload);
} catch {
return res.status(401).json({
success: false,
error: "Your unsubscribe link is invalid or has expired.",
code: "INVALID_TOKEN",
});
}
try {
const verified = await jwtVerify(
parsedBody.data.token,
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
{
// We intentionally do not use exp/iat claims
// tokens are long-lived and validated only by signature + payload.
clockTolerance: "0s",
},
);
payload = tokenPayloadSchema.parse(verified.payload);
} catch {
return res.status(401).json({
success: false,
error: "Your unsubscribe link is invalid or has expired.",
code: "INVALID_TOKEN",
});
}
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
try {
await novu.subscribers.preferences.update(
{
channels: {
email: false,
},
},
payload.subscriberId,
);
} catch (error) {
console.error("Failed to update Novu preferences", error);
return res.status(502).json({
success: false,
error:
"We could not update your email preferences right now. Please try again later.",
code: "NOVU_ERROR",
});
}
try {
await novu.subscribers.preferences.update(
{
channels: {
email: false,
},
},
payload.subscriberId,
);
} catch (error) {
return res.status(502).json({
success: false,
error:
"We could not update your email preferences right now. Please try again later.",
code: "NOVU_ERROR",
});
}
return res.status(200).json({ success: true });
},
return res.status(200).json({ success: true });
},
),
);

View File

@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { Upload } from "@aws-sdk/lib-storage";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { assertPermission } from "@kan/api/utils/permissions";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as cardRepo from "@kan/db/repository/card.repo";
@@ -22,7 +23,7 @@ export const config = {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
@@ -36,7 +37,9 @@ export default withRateLimit(
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
if (!bucket) {
return res.status(500).json({ error: "Attachments bucket not configured" });
return res
.status(500)
.json({ error: "Attachments bucket not configured" });
}
const cardPublicId = req.query.cardPublicId;
@@ -55,7 +58,9 @@ export default withRateLimit(
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
return res
.status(400)
.json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
@@ -129,8 +134,7 @@ export default withRateLimit(
return res.status(200).json({ attachment });
} catch (error) {
console.error("Attachment upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
}),
);

View File

@@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { createNextApiContext } from "@kan/api/trpc";
import * as userRepo from "@kan/db/repository/user.repo";
import { env } from "~/env";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as userRepo from "@kan/db/repository/user.repo";
import { createS3Client } from "@kan/shared/utils";
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
import { env } from "~/env";
const MAX_SIZE_BYTES = parseInt(
process.env.S3_AVATAR_UPLOAD_LIMIT || "2097152",
10,
); // Default 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {
@@ -19,7 +23,7 @@ export const config = {
export default withRateLimit(
{ points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => {
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
@@ -51,7 +55,9 @@ export default withRateLimit(
}
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return res.status(400).json({ error: "Missing or invalid content length" });
return res
.status(400)
.json({ error: "Missing or invalid content length" });
}
if (contentLength > MAX_SIZE_BYTES) {
@@ -93,9 +99,7 @@ export default withRateLimit(
user: updatedUser,
});
} catch (error) {
console.error("Avatar upload failed", error);
return res.status(500).json({ error: "Internal server error" });
}
},
}),
);

View File

@@ -0,0 +1,31 @@
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { env } from "next-runtime-env";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import SelectPlanView from "~/views/onboarding/select-plan";
export default function SelectPlanPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) {
router.push("/login");
}
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
router.push("/boards");
}
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Select plan | kan.bn" />
<SelectPlanView />
</>
);
}

View File

@@ -0,0 +1,28 @@
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import WorkspaceDetailsView from "~/views/onboarding/workspace-details";
export default function WorkspaceDetailsPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) router.push("/login");
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud")
router.push("/boards");
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Create workspace | kan.bn" />
<WorkspaceDetailsView />
</>
);
}

View File

@@ -17,7 +17,7 @@ interface Workspace {
description: string | null | undefined;
publicId: string;
slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined;
plan: "free" | "team" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
}
@@ -27,7 +27,7 @@ const initialWorkspace: Workspace = {
description: null,
publicId: "",
slug: "",
plan: "free",
plan: "free" as const,
role: "member",
weekStartDay: 1,
};
@@ -50,7 +50,15 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
const workspacePublicId = useSearchParams().get("workspacePublicId");
const { data, isLoading } = api.workspace.all.useQuery();
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string | null>(
workspacePublicId,
);
const pollAttemptsRef = React.useRef(0);
const MAX_POLL_ATTEMPTS = 5;
const { data, isLoading } = api.workspace.all.useQuery(undefined, {
refetchInterval: pendingWorkspaceId ? 2000 : false,
});
const utils = api.useUtils();
const switchWorkspace = (_workspace: Workspace) => {
@@ -94,7 +102,16 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
({ workspace }) => workspace.publicId === storedWorkspaceId,
);
if (!selectedWorkspace?.workspace) return;
if (!selectedWorkspace?.workspace) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
}
return;
}
pollAttemptsRef.current = 0;
setPendingWorkspaceId(null);
setWorkspace({
publicId: selectedWorkspace.workspace.publicId,

View File

@@ -0,0 +1,289 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Radio, RadioGroup } from "@headlessui/react";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { HiUser } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import { api } from "~/utils/api";
type PlanId = "solo" | "team" | "pro";
type Billing = "monthly" | "annual";
// Each user orbits via a zero-size pivot div at center (144,144) that rotates.
// The icon is offset from the pivot by `radius` pixels along the x-axis.
// A counter-rotation on the icon keeps it upright.
const ORBIT_USERS: Record<
PlanId,
{ id: string; angle: number; radius: number; duration: number }[]
> = {
solo: [],
team: [
{ id: "t1", angle: 45, radius: 104, duration: 50 },
{ id: "t2", angle: 170, radius: 104, duration: 50 },
{ id: "t3", angle: 290, radius: 104, duration: 50 },
],
pro: [
{ id: "t1", angle: 45, radius: 104, duration: 50 },
{ id: "t2", angle: 170, radius: 104, duration: 50 },
{ id: "t3", angle: 290, radius: 104, duration: 50 },
{ id: "p1", angle: 10, radius: 144, duration: 70 },
{ id: "p2", angle: 120, radius: 144, duration: 70 },
{ id: "p4", angle: 240, radius: 144, duration: 70 },
{ id: "p3", angle: 230, radius: 64, duration: 30 },
],
};
export default function SelectPlanView() {
const router = useRouter();
const searchParams = useSearchParams();
const [selected, setSelected] = useState<PlanId>(
(searchParams.get("plan") as PlanId | null) ?? "solo",
);
const [billing, setBilling] = useState<Billing>(
(searchParams.get("billing") as Billing | null) ?? "annual",
);
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const { data: workspaces } = api.workspace.all.useQuery();
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
enabled: !!session?.user,
});
const userImage = user?.image ?? session?.user.image ?? null;
const hasExistingWorkspace = !!workspaces?.length;
const FREQUENCIES: { value: Billing; label: string }[] = [
{ value: "monthly", label: t`Monthly` },
{ value: "annual", label: t`Annual` },
];
const PLANS: {
id: PlanId;
name: string;
monthly: string;
annual: string;
description: string;
trial?: boolean;
}[] = [
{
id: "solo",
name: t`Solo`,
monthly: t`Free`,
annual: t`Free`,
description: t`Good for individuals starting out who just need the essentials.`,
},
{
id: "team",
name: t`Team`,
monthly: "$10/user/mo",
annual: "$8/user/mo",
description: t`Best for small teams who want to collaborate and move faster together.`,
trial: true,
},
{
id: "pro",
name: t`Pro`,
monthly: "$29/mo",
annual: "$23/mo",
description: t`Unlimited members and a custom workspace username for teams ready to scale.`,
trial: true,
},
];
const handleSelectPlan = (plan: PlanId) => {
setSelected(plan);
router.replace(`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
};
const handleSetBilling = (b: Billing) => {
setBilling(b);
router.replace(`/onboarding/select-plan?plan=${selected}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`);
};
const handleContinue = () =>
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`);
const handleCancel = () => router.push(returnUrl);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
<div className="flex flex-col md:h-[520px] md:flex-row">
{/* Left panel */}
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
{t`Choose a plan`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`Pick a plan to get started. All paid plans include a 14-day free trial.`}
</p>
{/* Billing toggle */}
<div className="mt-4 flex justify-end">
<RadioGroup
value={billing}
onChange={handleSetBilling}
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
>
{FREQUENCIES.map((f) => (
<Radio
key={f.value}
value={f.value}
className={twMerge(
"cursor-pointer rounded-full px-2.5 py-0.5 text-xs transition-colors",
billing === f.value
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
: "text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200",
)}
>
{f.label}
</Radio>
))}
</RadioGroup>
</div>
<div className="mt-4 space-y-3">
{PLANS.map((plan) => {
const badge =
billing === "annual" ? plan.annual : plan.monthly;
return (
<button
key={plan.id}
type="button"
onClick={() => handleSelectPlan(plan.id)}
className={`relative w-full rounded-lg border px-4 py-3 text-left transition-colors ${
selected === plan.id
? "border-light-700 bg-light-300 dark:border-dark-600 dark:bg-dark-200"
: "border-light-500 bg-light-200 hover:border-light-600 dark:border-dark-500 dark:bg-dark-100 dark:hover:border-dark-600"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 pr-8">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
{plan.name}
</span>
<span className="rounded-full bg-neutral-700 px-2 py-px text-[11px] font-medium text-neutral-200">
{badge}
</span>
{plan.trial && billing === "annual" && (
<span className="rounded-full bg-emerald-500/10 px-2 py-px text-[11px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400">
-20%
</span>
)}
</div>
<p className="mt-1 text-xs text-light-800 dark:text-dark-800">
{plan.description}
</p>
</div>
<div className="mt-0.5 flex-shrink-0">
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
selected === plan.id
? "border-light-900 bg-light-900 dark:border-dark-900 dark:bg-dark-900"
: "border-light-700 dark:border-dark-700"
}`}
>
{selected === plan.id && (
<div className="h-1.5 w-1.5 rounded-full bg-light-100 dark:bg-dark-100" />
)}
</div>
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="mt-6 flex justify-end gap-2 md:mt-8">
{hasExistingWorkspace && (
<Button variant="ghost" onClick={handleCancel}>
{t`Cancel`}
</Button>
)}
<Button onClick={handleContinue}>{t`Continue`}</Button>
</div>
</div>
{/* Right panel */}
<div className="hidden items-center justify-center bg-light-200 dark:bg-dark-200 md:flex md:w-[45%]">
<div className="relative h-72 w-72">
{/* Orbit rings */}
<div className="absolute inset-0 m-auto h-72 w-72 rounded-full border border-light-400 dark:border-dark-400" />
<div className="absolute inset-0 m-auto h-52 w-52 rounded-full border border-light-400 dark:border-dark-400" />
<div className="absolute inset-0 m-auto h-32 w-32 rounded-full border border-light-400 dark:border-dark-400" />
{/* Centre person */}
<div className="absolute inset-0 m-auto flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-light-300 dark:bg-dark-300">
{userImage ? (
<img
src={userImage}
alt={t`Your avatar`}
className="h-full w-full object-cover"
/>
) : (
<HiUser className="h-7 w-7 text-light-700 dark:text-dark-700" />
)}
</div>
{/* Orbiting users — zero-size pivot at center rotates; icon offset by radius stays on the ring */}
<AnimatePresence>
{ORBIT_USERS[selected].map((u) => (
<motion.div
key={u.id}
style={{ position: "absolute", left: 144, top: 144 }}
initial={{ opacity: 0, rotate: u.angle }}
animate={{ opacity: 1, rotate: u.angle + 360 }}
exit={{ opacity: 0 }}
transition={{
opacity: { duration: 0.3 },
rotate: {
duration: u.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
},
}}
>
{/* Counter-rotate icon so it stays upright */}
<motion.div
style={{
position: "absolute",
left: u.radius - 12,
top: -12,
}}
initial={{ rotate: -u.angle }}
animate={{ rotate: -(u.angle + 360) }}
transition={{
duration: u.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-light-400 dark:bg-dark-400"
>
<HiUser className="h-3.5 w-3.5 text-light-800 dark:text-dark-800" />
</motion.div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</div>
{!hasExistingWorkspace && (
<Button variant="ghost" onClick={() => authClient.signOut()}>
{t`Sign out`}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,367 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
import {
HiArrowLeft,
HiArrowPath,
HiArrowRight,
HiCheck,
HiEllipsisVertical,
HiInformationCircle,
HiLockClosed,
} from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import Input from "~/components/Input";
import LoadingSpinner from "~/components/LoadingSpinner";
import Toggle from "~/components/Toggle";
import { Tooltip } from "~/components/Tooltip";
import { useDebounce } from "~/hooks/useDebounce";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60);
}
export default function WorkspaceNameView() {
const router = useRouter();
const searchParams = useSearchParams();
const plan = searchParams.get("plan") ?? "solo";
const billing = searchParams.get("billing") ?? "annual";
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const { showPopup } = usePopup();
const [isProToggle, setIsProToggle] = useState(plan === "pro");
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
const [description, setDescription] = useState("");
const handleNameChange = (value: string) => {
setName(value);
if (isProToggle && !slugManuallyEdited) {
setSlug(slugify(value));
}
};
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
const slugAvailability = api.workspace.checkSlugAvailability.useQuery(
{ workspaceSlug: debouncedSlug },
{ enabled: isProToggle && debouncedSlug.length >= 3 && !isTyping },
);
const isSlugAvailable =
slugAvailability.data?.isAvailable && !slugAvailability.data?.isReserved;
const isSlugTaken =
slugAvailability.data?.isAvailable === false &&
!slugAvailability.data?.isReserved;
const isSlugReserved = slugAvailability.data?.isReserved === true;
const slugError = isSlugTaken
? t`This URL has already been taken`
: isSlugReserved
? t`This URL is reserved`
: undefined;
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
enabled: !!session?.user,
});
const { data: workspaces } = api.workspace.all.useQuery();
const hasExistingWorkspace = !!workspaces?.length;
const utils = api.useUtils();
const previewSlug = isProToggle
? slugify(slug) || slugify(name) || "your-workspace"
: "your-workspace";
const createWorkspace = api.workspace.create.useMutation({
onSuccess: async (workspace) => {
if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId);
void utils.workspace.all.invalidate();
router.push("/boards");
},
onError: () => {
showPopup({
header: t`Unable to create workspace`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false);
const handleContinue = async () => {
if (!name.trim()) return;
if (plan === "solo") {
createWorkspace.mutate({
name: name.trim(),
...(description.trim() && { description: description.trim() }),
});
return;
}
// team/pro: redirect to Stripe — workspace created on checkout_success
setIsRedirectingToCheckout(true);
try {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspaceName: name.trim(),
...(description.trim() && {
workspaceDescription: description.trim(),
}),
...(plan === "pro" && slug ? { workspaceSlug: slug } : {}),
cancelUrl: window.location.pathname + window.location.search,
successUrl: "/boards",
billing,
plan,
}),
});
const data = await response.json();
const url = (data as { url: string }).url;
if (url) {
window.location.href = url;
return;
}
} catch {
// fall through
}
setIsRedirectingToCheckout(false);
showPopup({
header: t`Unable to start checkout`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
};
useEffect(() => {
document.getElementById("workspace-name-input")?.focus();
}, []);
const displayName = user?.name ?? session?.user.name ?? "";
const BOARDS = [t`Roadmap`, t`Engineering`, t`Marketing`];
const [visibleCount, setVisibleCount] = useState(1);
useEffect(() => {
const timers = BOARDS.slice(1).map((_, i) =>
setTimeout(() => setVisibleCount(i + 2), (i + 1) * 2000),
);
return () => timers.forEach(clearTimeout);
}, []);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
<div className="flex flex-col md:h-[520px] md:flex-row">
{/* Left panel */}
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
{t`Set up your workspace`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`You can always change these later in settings.`}
</p>
<div className="mt-6 space-y-3">
<Input
id="workspace-name-input"
placeholder={t`Workspace name`}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleContinue()}
maxLength={64}
/>
<div className="space-y-2">
<Input
placeholder={t`your-workspace`}
value={isProToggle ? slug : t`your-workspace`}
onChange={(e) => {
setSlugManuallyEdited(true);
setSlug(
e.target.value
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60),
);
}}
disabled={!isProToggle}
prefix="kan.bn/"
className={
!isProToggle ? "cursor-not-allowed opacity-50" : ""
}
errorMessage={slugError}
iconRight={
!isProToggle ? (
<Tooltip
content={
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
}
placement="top"
delay={0}
>
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
</Tooltip>
) : isProToggle && slug.length >= 3 ? (
isTyping || slugAvailability.isPending ? (
<LoadingSpinner />
) : isSlugAvailable ? (
<HiCheck className="h-4 w-4 text-white" />
) : null
) : null
}
/>
</div>
{plan !== "pro" && (
<div className="pb-2">
<Toggle
isChecked={isProToggle}
onChange={() => setIsProToggle((v) => !v)}
label={t`Upgrade to Pro ($29/month)`}
labelPosition="after"
/>
</div>
)}
<div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t`Workspace description`}
maxLength={280}
rows={3}
className="block w-full resize-none rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
/>
<p className="mt-1 text-right text-[10px] text-light-700 dark:text-dark-700">
{description.length}/280
</p>
</div>
</div>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div className="ml-auto flex gap-2">
<Button
variant="ghost"
onClick={() =>
router.replace(
`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
)
}
>
{t`Back`}
</Button>
<Button
onClick={() => void handleContinue()}
disabled={
!name.trim() ||
createWorkspace.isPending ||
isRedirectingToCheckout ||
(isProToggle &&
slug.length >= 3 &&
(isTyping || slugAvailability.isPending || !!slugError))
}
isLoading={
createWorkspace.isPending || isRedirectingToCheckout
}
>
{t`Continue`}
</Button>
</div>
</div>
</div>
{/* Right panel — browser mockup */}
<div className="hidden flex-col bg-light-300 dark:bg-dark-200 md:flex md:w-[45%]">
{/* Browser chrome */}
<div className="flex items-center gap-2 border-b border-light-400 px-3 py-2.5 dark:border-dark-400">
<div className="flex items-center gap-1.5 text-light-700 dark:text-dark-700">
<HiArrowLeft className="h-3.5 w-3.5" />
<HiArrowRight className="h-3.5 w-3.5" />
<HiArrowPath className="h-3.5 w-3.5" />
</div>
<div className="flex flex-1 items-center gap-1.5 rounded-md bg-light-200 px-2.5 py-1 dark:bg-dark-300">
<HiLockClosed className="h-3 w-3 flex-shrink-0 text-light-700 dark:text-dark-700" />
<span className="truncate text-xs text-light-900 dark:text-dark-900">
kan.bn/
<span className="text-light-1000 dark:text-dark-1000">
{previewSlug}
</span>
</span>
</div>
<HiEllipsisVertical className="h-4 w-4 flex-shrink-0 text-light-700 dark:text-dark-700" />
</div>
{/* Browser content */}
<div className="flex flex-1 flex-col items-center overflow-hidden px-14 py-8">
{/* Workspace name + description */}
<p className="text-center text-xs font-bold text-light-1000 dark:text-dark-1000">
{name || t`Your workspace`}
</p>
<p className="mt-0.5 line-clamp-2 break-all text-center text-[10px] text-light-800 dark:text-dark-800">
{description || t`Your workspace description`}
</p>
{/* Boards area */}
<div className="mt-3 flex w-full flex-1 flex-col rounded-lg bg-light-200 p-2 dark:bg-dark-300">
<div className="flex flex-1 flex-col gap-1.5">
{BOARDS.map((board, i) => (
<motion.div
key={board}
initial={{ opacity: 0, y: -12 }}
animate={
i < visibleCount
? { opacity: 1, y: 0 }
: { opacity: 0, y: -12 }
}
transition={{
opacity: { duration: 0.2 },
y: { duration: 0.25, ease: "easeOut" },
}}
className="flex w-full flex-1 items-center justify-center rounded border border-dashed border-light-400 bg-light-50 px-3 text-[10px] font-medium text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"
>
{board}
</motion.div>
))}
</div>
</div>
{/* Footer */}
<p className="mt-2 text-[10px] font-semibold text-light-900 dark:text-dark-900">
kan.bn
</p>
</div>
</div>
</div>
</div>
{!hasExistingWorkspace && (
<Button variant="ghost" onClick={() => authClient.signOut()}>
{t`Sign out`}
</Button>
)}
</div>
);
}

View File

@@ -28,6 +28,10 @@
"types": "./dist/utils/rateLimit.d.ts",
"default": "./src/utils/rateLimit.ts"
},
"./utils/apiLogging": {
"types": "./dist/utils/apiLogging.d.ts",
"default": "./src/utils/apiLogging.ts"
},
"./utils/permissions": {
"types": "./dist/utils/permissions.d.ts",
"default": "./src/utils/permissions.ts"

View File

@@ -202,6 +202,7 @@ export const workspaceRouter = createTRPCRouter({
.input(
z.object({
name: z.string().min(1).max(64),
description: z.string().max(280).optional(),
slug: z
.string()
.min(3)
@@ -260,6 +261,7 @@ export const workspaceRouter = createTRPCRouter({
slug: workspaceSlug,
createdBy: userId,
createdByEmail: userEmail,
...(input.description && { description: input.description }),
});
if (!result.publicId)

View File

@@ -0,0 +1,64 @@
import { randomUUID } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { createLogger } from "@kan/logger";
import { createNextApiContext } from "../trpc";
const log = createLogger("api");
const isCloud = process.env.NEXT_PUBLIC_KAN_ENV === "cloud";
export function withApiLogging(
handler: (
req: NextApiRequest,
res: NextApiResponse,
) => Promise<unknown> | unknown,
) {
return async (req: NextApiRequest, res: NextApiResponse) => {
const start = Date.now();
const requestId = randomUUID();
const route = req.url?.split("?")[0] ?? "unknown";
const input = {
...(req.query && 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;
const originalStatus = res.status.bind(res);
res.status = (code: number) => {
statusCode = code;
return originalStatus(code);
};
let userId: string | undefined;
let email: string | undefined;
try {
const ctx = await createNextApiContext(req);
userId = ctx.user?.id;
email = ctx.user?.email ?? undefined;
} catch {
// unauthenticated or auth unavailable
}
await handler(req, res);
const duration = Date.now() - start;
const meta = {
requestId,
procedure: route,
transport: "rest",
duration,
userId,
...(isCloud && email && { email }),
...(Object.keys(input).length > 0 && { input }),
status: statusCode,
};
if (statusCode < 400) {
log.info(meta, "API OK");
} else {
log.error(meta, "API error");
}
};
}

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."workspace_plan" ADD VALUE 'team' BEFORE 'pro';

File diff suppressed because it is too large Load Diff

View File

@@ -218,6 +218,13 @@
"when": 1773212242728,
"tag": "20260311065722_AddWeekStartDay",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1775170588247,
"tag": "20260402225628_AddTeamWorkspacePlan",
"breakpoints": true
}
]
}

View File

@@ -64,6 +64,8 @@ export const create = async (
slug: string;
createdBy: string;
createdByEmail: string;
description?: string;
plan?: "free" | "team" | "pro" | "enterprise";
},
) => {
const [workspace] = await db
@@ -73,6 +75,8 @@ export const create = async (
name: workspaceInput.name,
slug: workspaceInput.slug,
createdBy: workspaceInput.createdBy,
...(workspaceInput.description && { description: workspaceInput.description }),
...(workspaceInput.plan && { plan: workspaceInput.plan }),
})
.returning({
id: workspaces.id,
@@ -124,7 +128,7 @@ export const update = async (
workspaceInput: {
name?: string;
slug?: string;
plan?: "free" | "pro" | "enterprise";
plan?: "free" | "team" | "pro" | "enterprise";
description?: string;
showEmailsToMembers?: boolean;
weekStartDay?: number;

View File

@@ -34,7 +34,7 @@ export const slugTypes = ["reserved", "premium"] as const;
export type SlugType = (typeof slugTypes)[number];
export const slugTypeEnum = pgEnum("slug_type", slugTypes);
export const workspacePlans = ["free", "pro", "enterprise"] as const;
export const workspacePlans = ["free", "team", "pro", "enterprise"] as const;
export type WorkspacePlan = (typeof workspacePlans)[number];
export const workspacePlanEnum = pgEnum("workspace_plan", workspacePlans);