Compare commits
15 Commits
main
...
feat/onboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a31885e265 | ||
|
|
5c5d208fc4 | ||
|
|
cb76926041 | ||
|
|
6dfee580f7 | ||
|
|
e20104a292 | ||
|
|
dbf53717db | ||
|
|
0dc5092ebc | ||
|
|
e81f5d7fe6 | ||
|
|
f4bb7f8443 | ||
|
|
ac2d675934 | ||
|
|
edc9e1254e | ||
|
|
a713d09c46 | ||
|
|
385bc0de61 | ||
|
|
1e93d85318 | ||
|
|
abaf2badb0 |
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
28
apps/web/src/pages/onboarding/workspace.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
289
apps/web/src/views/onboarding/select-plan/index.tsx
Normal file
289
apps/web/src/views/onboarding/select-plan/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
apps/web/src/views/onboarding/workspace-details/index.tsx
Normal file
367
apps/web/src/views/onboarding/workspace-details/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
64
packages/api/src/utils/apiLogging.ts
Normal file
64
packages/api/src/utils/apiLogging.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."workspace_plan" ADD VALUE 'team' BEFORE 'pro';
|
||||
3869
packages/db/migrations/meta/20260402225628_snapshot.json
Normal file
3869
packages/db/migrations/meta/20260402225628_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,13 @@
|
||||
"when": 1773212242728,
|
||||
"tag": "20260311065722_AddWeekStartDay",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1775170588247,
|
||||
"tag": "20260402225628_AddTeamWorkspacePlan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user