feat: Polar license key integration and dashboard UI improvements
- Add license activation flow: server validates/activates keys via Polar SDK - Auto-activate from Polar checkout redirect (checkout_id in URL) - Gate dashboard behind license activation (redirect to /activate if no plan) - Preserve redirect URL through login flow for post-purchase activation - Show plan status badge in sidebar and overview page - Add account section to settings (email, plan, license key) - Add svelte-sonner toasts with custom black theme and iconify icons - Improve validation messages across all forms - Update API key prefix to droidclaw_ - Add cursor pointer globally for clickable elements - Support Polar sandbox mode via POLAR_SANDBOX env flag
This commit is contained in:
@@ -9,9 +9,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@droidclaw/shared": "workspace:*",
|
||||
"hono": "^4.7.0",
|
||||
"@polar-sh/sdk": "^0.43.1",
|
||||
"better-auth": "^1.3.27",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"hono": "^4.7.0",
|
||||
"postgres": "^3.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,6 +3,9 @@ export const env = {
|
||||
PORT: parseInt(process.env.PORT || "8080"),
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || "http://localhost:5173",
|
||||
INTERNAL_SECRET: process.env.INTERNAL_SECRET || "",
|
||||
POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN || "",
|
||||
POLAR_ORGANIZATION_ID: process.env.POLAR_ORGANIZATION_ID || "",
|
||||
POLAR_SANDBOX: process.env.POLAR_SANDBOX || "false",
|
||||
};
|
||||
|
||||
if (!env.DATABASE_URL) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { WebSocketData } from "./ws/sessions.js";
|
||||
import { devices } from "./routes/devices.js";
|
||||
import { goals } from "./routes/goals.js";
|
||||
import { health } from "./routes/health.js";
|
||||
import { license } from "./routes/license.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -34,6 +35,7 @@ app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
||||
app.route("/devices", devices);
|
||||
app.route("/goals", goals);
|
||||
app.route("/health", health);
|
||||
app.route("/license", license);
|
||||
|
||||
// Start server with WebSocket support
|
||||
const server = Bun.serve<WebSocketData>({
|
||||
|
||||
186
server/src/routes/license.ts
Normal file
186
server/src/routes/license.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Hono } from "hono";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Polar } from "@polar-sh/sdk";
|
||||
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
|
||||
import { db } from "../db.js";
|
||||
import { user as userTable } from "../schema.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
const license = new Hono<AuthEnv>();
|
||||
license.use("*", sessionMiddleware);
|
||||
|
||||
const getPolar = () =>
|
||||
new Polar({
|
||||
accessToken: env.POLAR_ACCESS_TOKEN,
|
||||
server: env.POLAR_SANDBOX === "true" ? "sandbox" : "production",
|
||||
});
|
||||
|
||||
/** POST /license/activate — validate + activate a Polar license key */
|
||||
license.post("/activate", async (c) => {
|
||||
const currentUser = c.get("user");
|
||||
const { key } = await c.req.json<{ key: string }>();
|
||||
|
||||
if (!key) {
|
||||
return c.json({ error: "License key is required" }, 400);
|
||||
}
|
||||
|
||||
if (!env.POLAR_ACCESS_TOKEN || !env.POLAR_ORGANIZATION_ID) {
|
||||
return c.json({ error: "Payment system not configured" }, 500);
|
||||
}
|
||||
|
||||
// Check if user already has a plan
|
||||
const existing = await db
|
||||
.select({ plan: userTable.plan })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, currentUser.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]?.plan) {
|
||||
return c.json({ error: "Account already activated" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const polar = getPolar();
|
||||
|
||||
// Validate the license key with Polar
|
||||
const result = await polar.licenseKeys.validate({
|
||||
key,
|
||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||
});
|
||||
|
||||
if (result.status !== "granted") {
|
||||
return c.json({ error: "Invalid or revoked license key" }, 400);
|
||||
}
|
||||
|
||||
// Determine plan from benefit ID or default to "ltd"
|
||||
const plan = "ltd";
|
||||
|
||||
// Activate the key (tracks activation count on Polar's side)
|
||||
await polar.licenseKeys.activate({
|
||||
key,
|
||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||
label: `${currentUser.email}`,
|
||||
});
|
||||
|
||||
// Store on user record
|
||||
await db
|
||||
.update(userTable)
|
||||
.set({
|
||||
plan,
|
||||
polarLicenseKey: result.displayKey ?? key.slice(0, 8) + "...",
|
||||
polarCustomerId: (result as Record<string, unknown>).userId as string ?? null,
|
||||
})
|
||||
.where(eq(userTable.id, currentUser.id));
|
||||
|
||||
return c.json({ success: true, plan });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[License] Activation failed for ${currentUser.email}:`, message);
|
||||
|
||||
if (message.includes("not found") || message.includes("invalid")) {
|
||||
return c.json({ error: "Invalid license key" }, 400);
|
||||
}
|
||||
if (message.includes("limit")) {
|
||||
return c.json({ error: "License key activation limit reached" }, 400);
|
||||
}
|
||||
|
||||
return c.json({ error: "Failed to validate license key" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /license/activate-checkout — auto-activate from Polar checkout ID */
|
||||
license.post("/activate-checkout", async (c) => {
|
||||
const currentUser = c.get("user");
|
||||
const { checkoutId } = await c.req.json<{ checkoutId: string }>();
|
||||
|
||||
if (!checkoutId) {
|
||||
return c.json({ error: "Checkout ID is required" }, 400);
|
||||
}
|
||||
|
||||
if (!env.POLAR_ACCESS_TOKEN || !env.POLAR_ORGANIZATION_ID) {
|
||||
return c.json({ error: "Payment system not configured" }, 500);
|
||||
}
|
||||
|
||||
// Check if user already has a plan
|
||||
const existing = await db
|
||||
.select({ plan: userTable.plan })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, currentUser.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]?.plan) {
|
||||
return c.json({ error: "Account already activated" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const polar = getPolar();
|
||||
|
||||
// 1. Get checkout session to find the customer
|
||||
const checkout = await polar.checkouts.get({ id: checkoutId });
|
||||
|
||||
if (checkout.status !== "succeeded") {
|
||||
return c.json({ error: "Checkout not completed yet" }, 400);
|
||||
}
|
||||
|
||||
if (!checkout.customerId) {
|
||||
return c.json({ error: "No customer found for this checkout" }, 400);
|
||||
}
|
||||
|
||||
// 2. List license keys for the org and find one for this customer
|
||||
const keysPage = await polar.licenseKeys.list({
|
||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const customerKey = keysPage.result.items.find(
|
||||
(k) => k.customerId === checkout.customerId && k.status === "granted"
|
||||
);
|
||||
|
||||
if (!customerKey) {
|
||||
return c.json({ error: "No license key found for this purchase" }, 400);
|
||||
}
|
||||
|
||||
// 3. Activate the key
|
||||
await polar.licenseKeys.activate({
|
||||
key: customerKey.key,
|
||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||
label: `${currentUser.email}`,
|
||||
});
|
||||
|
||||
// 4. Store on user record
|
||||
await db
|
||||
.update(userTable)
|
||||
.set({
|
||||
plan: "ltd",
|
||||
polarLicenseKey: customerKey.displayKey,
|
||||
polarCustomerId: checkout.customerId,
|
||||
})
|
||||
.where(eq(userTable.id, currentUser.id));
|
||||
|
||||
return c.json({ success: true, plan: "ltd" });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[License] Checkout activation failed for ${currentUser.email}:`, message);
|
||||
return c.json({ error: "Failed to activate from checkout" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /license/status — check current user's plan */
|
||||
license.get("/status", async (c) => {
|
||||
const currentUser = c.get("user");
|
||||
|
||||
const rows = await db
|
||||
.select({ plan: userTable.plan, polarLicenseKey: userTable.polarLicenseKey })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, currentUser.id))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
return c.json({
|
||||
activated: !!row?.plan,
|
||||
plan: row?.plan ?? null,
|
||||
licenseKey: row?.polarLicenseKey ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
export { license };
|
||||
@@ -6,6 +6,9 @@ export const user = pgTable("user", {
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
plan: text("plan"),
|
||||
polarLicenseKey: text("polar_license_key"),
|
||||
polarCustomerId: text("polar_customer_id"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
|
||||
Reference in New Issue
Block a user