Compare commits
3 Commits
fix/react-
...
feat/partn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8863d43793 | ||
|
|
9837ddd8af | ||
|
|
e34518e654 |
19
apps/web/src/pages/api/partner/_utils.ts
Normal file
19
apps/web/src/pages/api/partner/_utils.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
|
||||||
|
|
||||||
|
export interface TierConfig {
|
||||||
|
plan: WorkspacePlan;
|
||||||
|
seats: number | null;
|
||||||
|
unlimitedSeats: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_MAP: Record<number, TierConfig> = {
|
||||||
|
1: { plan: "team", seats: 5, unlimitedSeats: false },
|
||||||
|
2: { plan: "pro", seats: 15, unlimitedSeats: false },
|
||||||
|
3: { plan: "pro", seats: null, unlimitedSeats: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tierConfig(tier: number): TierConfig {
|
||||||
|
const config = TIER_MAP[tier];
|
||||||
|
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
168
apps/web/src/pages/api/partner/callback.ts
Normal file
168
apps/web/src/pages/api/partner/callback.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
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 { createLogger } from "@kan/logger";
|
||||||
|
|
||||||
|
import { tierConfig } from "./_utils";
|
||||||
|
|
||||||
|
const log = createLogger("api");
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthLicenseResponse {
|
||||||
|
license_key: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LicenseDetailResponse {
|
||||||
|
license_key: string;
|
||||||
|
status: string;
|
||||||
|
tier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
|
||||||
|
const tokenUrl = process.env.PARTNER_TOKEN_URL;
|
||||||
|
const clientId = process.env.PARTNER_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
|
||||||
|
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
|
||||||
|
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
|
||||||
|
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
|
||||||
|
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
|
||||||
|
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
|
||||||
|
|
||||||
|
const res = await fetch(tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<TokenResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOAuthLicense(
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<OAuthLicenseResponse> {
|
||||||
|
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
|
||||||
|
if (!oauthLicenseUrl)
|
||||||
|
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
|
||||||
|
|
||||||
|
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
|
||||||
|
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<OAuthLicenseResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLicenseDetail(
|
||||||
|
licenseKey: string,
|
||||||
|
): Promise<LicenseDetailResponse> {
|
||||||
|
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
|
||||||
|
const apiKey = process.env.PARTNER_API_KEY;
|
||||||
|
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
|
||||||
|
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
|
||||||
|
|
||||||
|
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
|
||||||
|
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
|
||||||
|
|
||||||
|
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
|
||||||
|
headers: { [apiKeyHeader]: apiKey },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<LicenseDetailResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 20, duration: 60 },
|
||||||
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code } = req.query;
|
||||||
|
|
||||||
|
if (!code || typeof code !== "string") {
|
||||||
|
return res.status(400).json({ message: "Missing code parameter" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let license: LicenseDetailResponse;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenData = await exchangeCodeForToken(code);
|
||||||
|
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
|
||||||
|
license = await fetchLicenseDetail(oauthLicense.license_key);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "Partner OAuth flow failed");
|
||||||
|
return res.redirect(`/partner/activate?error=oauth_failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db, user } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
const cfg = tierConfig(license.tier);
|
||||||
|
const isActive = license.status === "active";
|
||||||
|
const status = isActive ? "active" : "inactive";
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
await subscriptionRepo.upsertByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license.license_key,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status,
|
||||||
|
partnerTier: license.tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.redirect(
|
||||||
|
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||||
|
const workspace = memberships?.[0]?.workspace;
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
await subscriptionRepo.upsertByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license.license_key,
|
||||||
|
{
|
||||||
|
plan: cfg.plan,
|
||||||
|
status,
|
||||||
|
partnerTier: license.tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.redirect(
|
||||||
|
`/onboarding?license_key=${encodeURIComponent(license.license_key)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
status,
|
||||||
|
partnerTier: license.tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
referenceId: workspace.publicId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.redirect(`/?partner_activated=1`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
64
apps/web/src/pages/api/partner/link.ts
Normal file
64
apps/web/src/pages/api/partner/link.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default withRateLimit(
|
||||||
|
{ points: 20, duration: 60 },
|
||||||
|
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { license_key } = req.query;
|
||||||
|
|
||||||
|
if (!license_key || typeof license_key !== "string") {
|
||||||
|
return res.redirect("/?partner_error=missing_license");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { db, user } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.redirect(
|
||||||
|
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
return res.redirect("/?partner_error=invalid_license");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub.status !== "active") {
|
||||||
|
return res.redirect("/?partner_error=license_inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||||
|
const workspace = memberships?.[0]?.workspace;
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return res.redirect(
|
||||||
|
`/onboarding?license_key=${encodeURIComponent(license_key)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: sub.plan,
|
||||||
|
status: sub.status,
|
||||||
|
partnerTier: sub.partnerTier ?? 1,
|
||||||
|
seats: sub.seats ?? null,
|
||||||
|
unlimitedSeats: sub.unlimitedSeats,
|
||||||
|
referenceId: workspace.publicId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await workspaceRepo.update(db, workspace.publicId, {
|
||||||
|
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.redirect("/?partner_activated=1");
|
||||||
|
}),
|
||||||
|
);
|
||||||
192
apps/web/src/pages/api/partner/webhook.ts
Normal file
192
apps/web/src/pages/api/partner/webhook.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from "crypto";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
import { createNextApiContext } from "@kan/api/trpc";
|
||||||
|
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||||
|
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||||
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
|
import { createLogger } from "@kan/logger";
|
||||||
|
|
||||||
|
import { tierConfig } from "./_utils";
|
||||||
|
|
||||||
|
const log = createLogger("api");
|
||||||
|
|
||||||
|
async function buffer(readable: Readable) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of readable) {
|
||||||
|
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySignature(
|
||||||
|
rawBody: string,
|
||||||
|
signature: string,
|
||||||
|
timestamp: string,
|
||||||
|
): boolean {
|
||||||
|
const secret = process.env.PARTNER_API_KEY;
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
const ts = Number(timestamp);
|
||||||
|
if (isNaN(ts) || Date.now() / 1000 - ts > 300) return false;
|
||||||
|
|
||||||
|
const payload = `${timestamp}.${rawBody}`;
|
||||||
|
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookPayload {
|
||||||
|
event:
|
||||||
|
| "purchase"
|
||||||
|
| "activate"
|
||||||
|
| "deactivate"
|
||||||
|
| "upgrade"
|
||||||
|
| "downgrade"
|
||||||
|
| "migrate";
|
||||||
|
license_key: string;
|
||||||
|
license_status: string;
|
||||||
|
tier: number;
|
||||||
|
prev_license_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withApiLogging(
|
||||||
|
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ message: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
|
||||||
|
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
|
||||||
|
|
||||||
|
if (!signatureHeader || !timestampHeader) {
|
||||||
|
log.error("Webhook signature headers not configured");
|
||||||
|
return res.status(500).json({ message: "Server misconfiguration" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = req.headers[signatureHeader] as string | undefined;
|
||||||
|
const timestamp = req.headers[timestampHeader] as string | undefined;
|
||||||
|
|
||||||
|
if (!signature || !timestamp) {
|
||||||
|
return res.status(400).json({ message: "Missing signature headers" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = await buffer(req as unknown as Readable);
|
||||||
|
const rawBody = buf.toString("utf8");
|
||||||
|
|
||||||
|
if (!verifySignature(rawBody, signature, timestamp)) {
|
||||||
|
return res.status(401).json({ message: "Invalid signature" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(rawBody) as WebhookPayload;
|
||||||
|
const { event, license_key, license_status, tier, prev_license_key } =
|
||||||
|
payload;
|
||||||
|
|
||||||
|
const { db } = await createNextApiContext(req);
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case "purchase": {
|
||||||
|
const cfg = tierConfig(tier);
|
||||||
|
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: license_status,
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "activate": {
|
||||||
|
const cfg = tierConfig(tier);
|
||||||
|
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: "active",
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "deactivate": {
|
||||||
|
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
);
|
||||||
|
if (sub) {
|
||||||
|
await subscriptionRepo.updateById(db, sub.id, {
|
||||||
|
plan: "free",
|
||||||
|
status: "inactive",
|
||||||
|
});
|
||||||
|
if (sub.referenceId) {
|
||||||
|
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "upgrade":
|
||||||
|
case "downgrade": {
|
||||||
|
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
license_key,
|
||||||
|
);
|
||||||
|
if (sub) {
|
||||||
|
const cfg = tierConfig(tier);
|
||||||
|
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: license_status,
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
});
|
||||||
|
if (sub.referenceId) {
|
||||||
|
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "migrate": {
|
||||||
|
if (prev_license_key) {
|
||||||
|
const sub = await subscriptionRepo.getByPartnerLicenseKey(
|
||||||
|
db,
|
||||||
|
prev_license_key,
|
||||||
|
);
|
||||||
|
if (sub) {
|
||||||
|
const cfg = tierConfig(tier);
|
||||||
|
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
|
||||||
|
plan: cfg.plan,
|
||||||
|
status: sub.status,
|
||||||
|
partnerTier: tier,
|
||||||
|
seats: cfg.seats,
|
||||||
|
unlimitedSeats: cfg.unlimitedSeats,
|
||||||
|
referenceId: sub.referenceId ?? undefined,
|
||||||
|
});
|
||||||
|
await subscriptionRepo.updateById(db, sub.id, {
|
||||||
|
status: "inactive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.warn({ event }, "Unhandled partner webhook event");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ success: true, event });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
98
apps/web/src/pages/partner/activate.tsx
Normal file
98
apps/web/src/pages/partner/activate.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
|
import { Auth } from "~/components/AuthForm";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
|
|
||||||
|
export default function PartnerActivatePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const licenseKey = searchParams.get("license_key");
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
|
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && session?.user && licenseKey) {
|
||||||
|
router.push(
|
||||||
|
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
|
||||||
|
);
|
||||||
|
} else if (!isPending && session?.user && !licenseKey) {
|
||||||
|
router.push("/boards");
|
||||||
|
}
|
||||||
|
}, [session, isPending, licenseKey, router]);
|
||||||
|
|
||||||
|
if (isPending || (session?.user && licenseKey)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title={t`Activate | kan.bn`} />
|
||||||
|
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||||
|
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||||
|
<div className="z-10 flex w-full flex-col items-center">
|
||||||
|
<Link href="/">
|
||||||
|
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
kan.bn
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
<p className="mb-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||||
|
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
|
||||||
|
</p>
|
||||||
|
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
|
||||||
|
{isMagicLinkSent ? (
|
||||||
|
<Trans>We sent a link to {magicLinkRecipient}</Trans>
|
||||||
|
) : (
|
||||||
|
t`Sign in or create an account to activate your license`
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
|
||||||
|
{t`Something went wrong during activation. Please try again.`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMagicLinkSent && (
|
||||||
|
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||||
|
<Auth
|
||||||
|
setIsMagicLinkSent={(val, recipient) => {
|
||||||
|
setIsMagicLinkSent(val);
|
||||||
|
setMagicLinkRecipient(recipient);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||||
|
<Trans>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<span className="underline">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
licenseKey
|
||||||
|
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
|
||||||
|
: "/login"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PatternedBackground />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "subscription" ADD COLUMN "partnerLicenseKey" varchar(255);--> statement-breakpoint
|
||||||
|
ALTER TABLE "subscription" ADD COLUMN "partnerTier" integer;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "subscription_partner_license_key_idx" ON "subscription" USING btree ("partnerLicenseKey");
|
||||||
3955
packages/db/migrations/meta/20260512203226_snapshot.json
Normal file
3955
packages/db/migrations/meta/20260512203226_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -232,6 +232,19 @@
|
|||||||
"when": 1776809379931,
|
"when": 1776809379931,
|
||||||
"tag": "20260421220939_AddCardNumber",
|
"tag": "20260421220939_AddCardNumber",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778600989523,
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778617946519,
|
||||||
|
"tag": "20260512203226_AddPartnerLicenseToSubscription",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ export const updateById = async (
|
|||||||
db: dbClient,
|
db: dbClient,
|
||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
updates: {
|
updates: {
|
||||||
|
plan?: string;
|
||||||
unlimitedSeats?: boolean;
|
unlimitedSeats?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
seats?: number | null;
|
seats?: number | null;
|
||||||
@@ -89,3 +90,47 @@ export const create = async (
|
|||||||
const [result] = await db.insert(subscription).values(data).returning();
|
const [result] = await db.insert(subscription).values(data).returning();
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getByPartnerLicenseKey = async (
|
||||||
|
db: dbClient,
|
||||||
|
partnerLicenseKey: string,
|
||||||
|
) => {
|
||||||
|
const result = await db.query.subscription.findFirst({
|
||||||
|
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertByPartnerLicenseKey = async (
|
||||||
|
db: dbClient,
|
||||||
|
partnerLicenseKey: string,
|
||||||
|
data: {
|
||||||
|
plan: string;
|
||||||
|
status: string;
|
||||||
|
partnerTier: number;
|
||||||
|
seats: number | null;
|
||||||
|
unlimitedSeats: boolean;
|
||||||
|
referenceId?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const existing = await getByPartnerLicenseKey(db, partnerLicenseKey);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [result] = await db
|
||||||
|
.update(subscription)
|
||||||
|
.set({ ...data, updatedAt: new Date() })
|
||||||
|
.where(eq(subscription.partnerLicenseKey, partnerLicenseKey))
|
||||||
|
.returning();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await db
|
||||||
|
.insert(subscription)
|
||||||
|
.values({
|
||||||
|
partnerLicenseKey,
|
||||||
|
...data,
|
||||||
|
referenceId: data.referenceId ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,31 +5,42 @@ import {
|
|||||||
integer,
|
integer,
|
||||||
pgTable,
|
pgTable,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { workspaces } from "./workspaces";
|
import { workspaces } from "./workspaces";
|
||||||
|
|
||||||
export const subscription = pgTable("subscription", {
|
export const subscription = pgTable(
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
"subscription",
|
||||||
plan: varchar("plan", { length: 255 }).notNull(),
|
{
|
||||||
referenceId: varchar("referenceId", { length: 12 }).references(
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
() => workspaces.publicId,
|
plan: varchar("plan", { length: 255 }).notNull(),
|
||||||
{ onDelete: "set null" },
|
referenceId: varchar("referenceId", { length: 12 }).references(
|
||||||
),
|
() => workspaces.publicId,
|
||||||
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
{ onDelete: "set null" },
|
||||||
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
|
),
|
||||||
status: varchar("status", { length: 255 }).notNull(),
|
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
||||||
periodStart: timestamp("periodStart"),
|
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
|
||||||
periodEnd: timestamp("periodEnd"),
|
status: varchar("status", { length: 255 }).notNull(),
|
||||||
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
periodStart: timestamp("periodStart"),
|
||||||
seats: integer("seats"),
|
periodEnd: timestamp("periodEnd"),
|
||||||
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
|
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
||||||
trialStart: timestamp("trialStart"),
|
seats: integer("seats"),
|
||||||
trialEnd: timestamp("trialEnd"),
|
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
|
||||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
trialStart: timestamp("trialStart"),
|
||||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
trialEnd: timestamp("trialEnd"),
|
||||||
}).enableRLS();
|
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
|
||||||
|
partnerTier: integer("partnerTier"),
|
||||||
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("subscription_partner_license_key_idx").on(
|
||||||
|
table.partnerLicenseKey,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).enableRLS();
|
||||||
|
|
||||||
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
|
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
|
|||||||
Reference in New Issue
Block a user