feat(cloud): improve partner onboarding

This commit is contained in:
Henry
2026-05-15 22:33:14 +01:00
parent 0c2b670baf
commit 8436644966
6 changed files with 135 additions and 63 deletions

View File

@@ -46,6 +46,7 @@ interface FormValues {
interface AuthProps { interface AuthProps {
setIsMagicLinkSent: (value: boolean, recipient: string) => void; setIsMagicLinkSent: (value: boolean, recipient: string) => void;
isSignUp?: boolean; isSignUp?: boolean;
callbackURL?: string;
} }
const EmailSchema = z.object({ const EmailSchema = z.object({
@@ -152,7 +153,11 @@ const availableSocialProviders = {
}, },
}; };
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { export function Auth({
setIsMagicLinkSent,
isSignUp,
callbackURL: callbackURLProp,
}: AuthProps) {
const [isCloudEnv, setIsCloudEnv] = useState(false); const [isCloudEnv, setIsCloudEnv] = useState(false);
const [isLoginWithProviderPending, setIsLoginWithProviderPending] = const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
useState<null | AuthProvider>(null); useState<null | AuthProvider>(null);
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
const passwordRef = useRef<HTMLInputElement | null>(null); const passwordRef = useRef<HTMLInputElement | null>(null);
const redirect = useSearchParams().get("next"); const redirect = useSearchParams().get("next");
const callbackURL = redirect ?? "/boards"; const callbackURL = callbackURLProp ?? redirect ?? "/boards";
// Safely get environment variables on client side to avoid hydration mismatch // Safely get environment variables on client side to avoid hydration mismatch
useEffect(() => { useEffect(() => {

View File

@@ -1,4 +1,4 @@
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@@ -14,6 +14,7 @@ import { authClient } from "@kan/auth/client";
import { useClickOutside } from "~/hooks/useClickOutside"; import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace"; import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation"; import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
@@ -49,7 +50,9 @@ export default function Dashboard({
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const { openModal, closeModal, modalContentType } = useModal(); const { openModal, closeModal, modalContentType } = useModal();
const { availableWorkspaces, hasLoaded } = useWorkspace(); const { availableWorkspaces, hasLoaded } = useWorkspace();
const { showPopup } = usePopup();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending: sessionLoading } = authClient.useSession(); const { data: session, isPending: sessionLoading } = authClient.useSession();
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery( const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
@@ -103,6 +106,38 @@ export default function Dashboard({
} }
}); });
useEffect(() => {
const partnerActivated = searchParams.get("partner_activated");
const partnerError = searchParams.get("partner_error");
if (partnerActivated) {
showPopup({
header: t`License activated`,
message: t`Your license has been activated successfully.`,
icon: "success",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_activated");
router.replace(`?${params.toString()}`);
} else if (partnerError) {
const messages: Record<string, string> = {
invalid_license: t`That license key could not be found. Please contact support.`,
license_inactive: t`Your license is not active. Please check your account.`,
missing_license: t`No license key was provided. Please try activating again.`,
};
showPopup({
header: t`License activation failed`,
message:
messages[partnerError] ??
t`Something went wrong during license activation.`,
icon: "error",
});
const params = new URLSearchParams(searchParams.toString());
params.delete("partner_error");
router.replace(`?${params.toString()}`);
}
}, [searchParams, showPopup, router]);
useEffect(() => { useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) { if (hasLoaded && availableWorkspaces.length === 0) {
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") { if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {

View File

@@ -146,7 +146,7 @@ export default withRateLimit(
}, },
); );
return res.redirect( return res.redirect(
`/onboarding?license_key=${encodeURIComponent(license.license_key)}`, `/onboarding/workspace?license_key=${encodeURIComponent(license.license_key)}`,
); );
} }

View File

@@ -16,7 +16,7 @@ export default withRateLimit(
const { license_key } = req.query; const { license_key } = req.query;
if (!license_key || typeof license_key !== "string") { if (!license_key || typeof license_key !== "string") {
return res.redirect("/?partner_error=missing_license"); return res.redirect("/boards?partner_error=missing_license");
} }
const { db, user } = await createNextApiContext(req); const { db, user } = await createNextApiContext(req);
@@ -30,11 +30,11 @@ export default withRateLimit(
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key); const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
if (!sub) { if (!sub) {
return res.redirect("/?partner_error=invalid_license"); return res.redirect("/boards?partner_error=invalid_license");
} }
if (sub.status !== "active") { if (sub.status !== "active") {
return res.redirect("/?partner_error=license_inactive"); return res.redirect("/boards?partner_error=license_inactive");
} }
const memberships = await workspaceRepo.getAllByUserId(db, user.id); const memberships = await workspaceRepo.getAllByUserId(db, user.id);
@@ -42,7 +42,7 @@ export default withRateLimit(
if (!workspace) { if (!workspace) {
return res.redirect( return res.redirect(
`/onboarding?license_key=${encodeURIComponent(license_key)}`, `/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
); );
} }
@@ -59,6 +59,6 @@ export default withRateLimit(
plan: sub.plan as "free" | "team" | "pro" | "enterprise", plan: sub.plan as "free" | "team" | "pro" | "enterprise",
}); });
return res.redirect("/?partner_activated=1"); return res.redirect("/boards?partner_activated=1");
}), }),
); );

View File

@@ -20,6 +20,12 @@ export default function PartnerActivatePage() {
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState(""); const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
useEffect(() => {
if (licenseKey) {
localStorage.setItem("partnerLicenseKey", licenseKey);
}
}, [licenseKey]);
useEffect(() => { useEffect(() => {
if (!isPending && session?.user && licenseKey) { if (!isPending && session?.user && licenseKey) {
router.push( router.push(
@@ -68,6 +74,11 @@ export default function PartnerActivatePage() {
setIsMagicLinkSent(val); setIsMagicLinkSent(val);
setMagicLinkRecipient(recipient); setMagicLinkRecipient(recipient);
}} }}
callbackURL={
licenseKey
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
: "/boards"
}
/> />
</div> </div>
</div> </div>

View File

@@ -39,7 +39,16 @@ export default function WorkspaceNameView() {
const plan = searchParams.get("plan") ?? "solo"; const plan = searchParams.get("plan") ?? "solo";
const billing = searchParams.get("billing") ?? "annual"; const billing = searchParams.get("billing") ?? "annual";
const returnUrl = searchParams.get("returnUrl") ?? "/boards"; const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const licenseKeyParam = searchParams.get("license_key");
const isLicenseFlow = !!licenseKeyParam;
const { showPopup } = usePopup(); const { showPopup } = usePopup();
useEffect(() => {
if (licenseKeyParam) {
localStorage.setItem("partnerLicenseKey", licenseKeyParam);
}
}, [licenseKeyParam]);
const [isProToggle, setIsProToggle] = useState(plan === "pro"); const [isProToggle, setIsProToggle] = useState(plan === "pro");
const effectivePlan = isProToggle ? "pro" : plan; const effectivePlan = isProToggle ? "pro" : plan;
@@ -94,7 +103,15 @@ export default function WorkspaceNameView() {
if (!workspace.publicId) return; if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId); localStorage.setItem("workspacePublicId", workspace.publicId);
void utils.workspace.all.invalidate(); void utils.workspace.all.invalidate();
router.push("/boards"); const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
if (storedLicenseKey) {
localStorage.removeItem("partnerLicenseKey");
router.push(
`/api/partner/link?license_key=${encodeURIComponent(storedLicenseKey)}`,
);
} else {
router.push("/boards");
}
}, },
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -110,7 +127,7 @@ export default function WorkspaceNameView() {
const handleContinue = async () => { const handleContinue = async () => {
if (!name.trim()) return; if (!name.trim()) return;
if (effectivePlan === "solo") { if (effectivePlan === "solo" || isLicenseFlow) {
createWorkspace.mutate({ createWorkspace.mutate({
name: name.trim(), name: name.trim(),
...(description.trim() && { description: description.trim() }), ...(description.trim() && { description: description.trim() }),
@@ -193,50 +210,52 @@ export default function WorkspaceNameView() {
maxLength={64} maxLength={64}
/> />
<div className="space-y-2"> {!isLicenseFlow && (
<Input <div className="space-y-2">
placeholder={t`your-workspace`} <Input
value={isProToggle ? slug : t`your-workspace`} placeholder={t`your-workspace`}
onChange={(e) => { value={isProToggle ? slug : t`your-workspace`}
setSlugManuallyEdited(true); onChange={(e) => {
setSlug( setSlugManuallyEdited(true);
e.target.value setSlug(
.toLowerCase() e.target.value
.replace(/[^a-z0-9\s-]/g, "") .toLowerCase()
.replace(/\s+/g, "-") .replace(/[^a-z0-9\s-]/g, "")
.replace(/-+/g, "-") .replace(/\s+/g, "-")
.slice(0, 60), .replace(/-+/g, "-")
); .slice(0, 60),
}} );
disabled={!isProToggle} }}
prefix="kan.bn/" disabled={!isProToggle}
className={ prefix="kan.bn/"
!isProToggle ? "cursor-not-allowed opacity-50" : "" className={
} !isProToggle ? "cursor-not-allowed opacity-50" : ""
errorMessage={slugError} }
iconRight={ errorMessage={slugError}
!isProToggle ? ( iconRight={
<Tooltip !isProToggle ? (
content={ <Tooltip
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span> content={
} <span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
placement="top" }
delay={0} placement="top"
> delay={0}
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" /> >
</Tooltip> <HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
) : isProToggle && slug.length >= 3 ? ( </Tooltip>
isTyping || slugAvailability.isPending ? ( ) : isProToggle && slug.length >= 3 ? (
<LoadingSpinner /> isTyping || slugAvailability.isPending ? (
) : isSlugAvailable ? ( <LoadingSpinner />
<HiCheck className="h-4 w-4 text-white" /> ) : isSlugAvailable ? (
<HiCheck className="h-4 w-4 text-white" />
) : null
) : null ) : null
) : null }
} />
/> </div>
</div> )}
{plan !== "pro" && ( {!isLicenseFlow && plan !== "pro" && (
<div className="pb-2"> <div className="pb-2">
<Toggle <Toggle
isChecked={isProToggle} isChecked={isProToggle}
@@ -265,16 +284,18 @@ export default function WorkspaceNameView() {
<div className="mt-6 flex flex-wrap items-center justify-between gap-3"> <div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<Button {!isLicenseFlow && (
variant="ghost" <Button
onClick={() => variant="ghost"
router.replace( onClick={() =>
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`, router.replace(
) `/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
} )
> }
{t`Back`} >
</Button> {t`Back`}
</Button>
)}
<Button <Button
onClick={() => void handleContinue()} onClick={() => void handleContinue()}
disabled={ disabled={