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:
Sanju Sivalingam
2026-02-18 22:11:37 +05:30
parent 5aace17096
commit b34088ceb7
28 changed files with 1555 additions and 87 deletions

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -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>({

View 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 };

View File

@@ -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()