diff --git a/server/package.json b/server/package.json index 228e87b..3bcd0ee 100644 --- a/server/package.json +++ b/server/package.json @@ -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": { diff --git a/server/src/env.ts b/server/src/env.ts index 35f7830..17f8dc6 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -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) { diff --git a/server/src/index.ts b/server/src/index.ts index c693ef0..4725f9b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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({ diff --git a/server/src/routes/license.ts b/server/src/routes/license.ts new file mode 100644 index 0000000..d079eb1 --- /dev/null +++ b/server/src/routes/license.ts @@ -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(); +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).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 }; diff --git a/server/src/schema.ts b/server/src/schema.ts index dd8fb24..6477ff4 100644 --- a/server/src/schema.ts +++ b/server/src/schema.ts @@ -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() diff --git a/web/drizzle/0003_familiar_green_goblin.sql b/web/drizzle/0003_familiar_green_goblin.sql new file mode 100644 index 0000000..2577eaa --- /dev/null +++ b/web/drizzle/0003_familiar_green_goblin.sql @@ -0,0 +1,3 @@ +ALTER TABLE "user" ADD COLUMN "plan" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "polar_license_key" text;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "polar_customer_id" text; \ No newline at end of file diff --git a/web/drizzle/meta/0003_snapshot.json b/web/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..cbbd1b8 --- /dev/null +++ b/web/drizzle/meta/0003_snapshot.json @@ -0,0 +1,811 @@ +{ + "id": "6adaa23d-98b8-457f-9329-588fd32c9570", + "prevId": "14e39517-17ec-4e0a-a053-4707f3269d32", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_session": { + "name": "agent_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "steps_used": { + "name": "steps_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_session_user_id_user_id_fk": { + "name": "agent_session_user_id_user_id_fk", + "tableFrom": "agent_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_session_device_id_device_id_fk": { + "name": "agent_session_device_id_device_id_fk", + "tableFrom": "agent_session", + "tableTo": "device", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "screen_hash": { + "name": "screen_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_step_session_id_agent_session_id_fk": { + "name": "agent_step_session_id_agent_session_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_user_id_fk": { + "name": "apikey_user_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device": { + "name": "device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "device_info": { + "name": "device_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_user_id_user_id_fk": { + "name": "device_user_id_user_id_fk", + "tableFrom": "device", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_config": { + "name": "llm_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "llm_config_user_id_user_id_fk": { + "name": "llm_config_user_id_user_id_fk", + "tableFrom": "llm_config", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "polar_license_key": { + "name": "polar_license_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/web/drizzle/meta/_journal.json b/web/drizzle/meta/_journal.json index d6089eb..3a6b6b5 100644 --- a/web/drizzle/meta/_journal.json +++ b/web/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1771326794177, "tag": "0002_fearless_greymalkin", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1771429350614, + "tag": "0003_familiar_green_goblin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/web/package.json b/web/package.json index 1dc6359..a3adf89 100644 --- a/web/package.json +++ b/web/package.json @@ -52,8 +52,13 @@ "vitest-browser-svelte": "^1.1.0" }, "dependencies": { + "@iconify/svelte": "^5.2.1", + "add": "^2.0.6", "better-auth": "^1.3.27", "postgres": "^3.4.7", + "shadcn-svelte": "^1.1.1", + "sonner": "^2.0.7", + "svelte-sonner": "^1.0.7", "valibot": "^1.1.0" } } diff --git a/web/src/app.css b/web/src/app.css index cd67023..8b6c4f2 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,3 +1,11 @@ @import 'tailwindcss'; @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; + +@theme { + --font-sans: 'Google Sans', 'Google Sans Text', system-ui, -apple-system, sans-serif; +} + +a, button, [role="button"], input[type="submit"], select, summary { + cursor: pointer; +} diff --git a/web/src/lib/api/api-keys.remote.ts b/web/src/lib/api/api-keys.remote.ts index 1bc90f8..f42b044 100644 --- a/web/src/lib/api/api-keys.remote.ts +++ b/web/src/lib/api/api-keys.remote.ts @@ -10,7 +10,7 @@ export const listKeys = query(async () => { export const createKey = form(createKeySchema, async ({ name }) => { const { request } = getRequestEvent(); const result = await auth.api.createApiKey({ - body: { name, prefix: 'dc' }, + body: { name, prefix: 'droidclaw_' }, headers: request.headers }); return result; diff --git a/web/src/lib/api/auth.remote.ts b/web/src/lib/api/auth.remote.ts index a627a64..de13453 100644 --- a/web/src/lib/api/auth.remote.ts +++ b/web/src/lib/api/auth.remote.ts @@ -9,9 +9,10 @@ export const signup = form(signupSchema, async (user) => { }); export const login = form(loginSchema, async (user) => { - const { request } = getRequestEvent(); + const { request, url } = getRequestEvent(); await auth.api.signInEmail({ body: user, headers: request.headers }); - redirect(303, '/dashboard'); + const next = url.searchParams.get('redirect') || '/dashboard'; + redirect(303, next); }); export const signout = form(async () => { diff --git a/web/src/lib/api/license.remote.ts b/web/src/lib/api/license.remote.ts new file mode 100644 index 0000000..5fbf451 --- /dev/null +++ b/web/src/lib/api/license.remote.ts @@ -0,0 +1,69 @@ +import { form, query, getRequestEvent } from '$app/server'; +import { redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { env } from '$env/dynamic/private'; +import { activateLicenseSchema, activateCheckoutSchema } from '$lib/schema/license'; + +export const getLicenseStatus = query(async () => { + const { locals } = getRequestEvent(); + if (!locals.user) return null; + + const rows = await db + .select({ plan: user.plan, polarLicenseKey: user.polarLicenseKey }) + .from(user) + .where(eq(user.id, locals.user.id)) + .limit(1); + + const row = rows[0]; + return { + activated: !!row?.plan, + plan: row?.plan ?? null, + licenseKey: row?.polarLicenseKey ?? null + }; +}); + +export const activateLicense = form(activateLicenseSchema, async (data) => { + const { locals } = getRequestEvent(); + if (!locals.user) return; + + const serverUrl = env.SERVER_URL || 'http://localhost:8080'; + const internalSecret = env.INTERNAL_SECRET || ''; + + const res = await fetch(`${serverUrl}/license/activate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-internal-secret': internalSecret, + 'x-internal-user-id': locals.user.id + }, + body: JSON.stringify({ key: data.key }) + }); + + if (res.ok) { + redirect(303, '/dashboard'); + } +}); + +export const activateFromCheckout = form(activateCheckoutSchema, async (data) => { + const { locals } = getRequestEvent(); + if (!locals.user) return; + + const serverUrl = env.SERVER_URL || 'http://localhost:8080'; + const internalSecret = env.INTERNAL_SECRET || ''; + + const res = await fetch(`${serverUrl}/license/activate-checkout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-internal-secret': internalSecret, + 'x-internal-user-id': locals.user.id + }, + body: JSON.stringify({ checkoutId: data.checkoutId }) + }); + + if (res.ok) { + redirect(303, '/dashboard'); + } +}); diff --git a/web/src/lib/api/settings.remote.ts b/web/src/lib/api/settings.remote.ts index cc02b81..9a074ff 100644 --- a/web/src/lib/api/settings.remote.ts +++ b/web/src/lib/api/settings.remote.ts @@ -51,4 +51,6 @@ export const updateConfig = form(llmConfigSchema, async (data) => { model: data.model ?? null }); } + + return { saved: true }; }); diff --git a/web/src/lib/components/IconToast.svelte b/web/src/lib/components/IconToast.svelte new file mode 100644 index 0000000..dcc8a9f --- /dev/null +++ b/web/src/lib/components/IconToast.svelte @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/schema/api-keys.ts b/web/src/lib/schema/api-keys.ts index 71f87db..1d8f0cf 100644 --- a/web/src/lib/schema/api-keys.ts +++ b/web/src/lib/schema/api-keys.ts @@ -1,9 +1,9 @@ import { object, string, pipe, minLength } from 'valibot'; export const createKeySchema = object({ - name: pipe(string(), minLength(1)) + name: pipe(string(), minLength(1, 'Please enter a name for the API key')) }); export const deleteKeySchema = object({ - keyId: pipe(string(), minLength(1)) + keyId: pipe(string(), minLength(1, 'Key ID is required')) }); diff --git a/web/src/lib/schema/auth.ts b/web/src/lib/schema/auth.ts index 10b9082..d10975c 100644 --- a/web/src/lib/schema/auth.ts +++ b/web/src/lib/schema/auth.ts @@ -1,12 +1,12 @@ import { object, string, pipe, minLength, email } from 'valibot'; export const signupSchema = object({ - name: pipe(string(), minLength(4)), - email: pipe(string(), minLength(1), email()), - password: pipe(string(), minLength(8)) + name: pipe(string(), minLength(4, 'Name must be at least 4 characters')), + email: pipe(string(), minLength(1, 'Email is required'), email('Please enter a valid email')), + password: pipe(string(), minLength(8, 'Password must be at least 8 characters')) }); export const loginSchema = object({ - email: pipe(string(), minLength(1), email()), - password: pipe(string(), minLength(8)) + email: pipe(string(), minLength(1, 'Email is required'), email('Please enter a valid email')), + password: pipe(string(), minLength(8, 'Password must be at least 8 characters')) }); diff --git a/web/src/lib/schema/license.ts b/web/src/lib/schema/license.ts new file mode 100644 index 0000000..ff3996c --- /dev/null +++ b/web/src/lib/schema/license.ts @@ -0,0 +1,9 @@ +import { object, string, pipe, minLength } from 'valibot'; + +export const activateLicenseSchema = object({ + key: pipe(string(), minLength(1, 'License key is required')) +}); + +export const activateCheckoutSchema = object({ + checkoutId: pipe(string(), minLength(1, 'Checkout ID is required')) +}); diff --git a/web/src/lib/schema/settings.ts b/web/src/lib/schema/settings.ts index 264ad66..40ffbc6 100644 --- a/web/src/lib/schema/settings.ts +++ b/web/src/lib/schema/settings.ts @@ -1,7 +1,7 @@ import { object, string, pipe, minLength, optional } from 'valibot'; export const llmConfigSchema = object({ - provider: pipe(string(), minLength(1)), - apiKey: pipe(string(), minLength(1)), + provider: pipe(string(), minLength(1, 'Please select a provider')), + apiKey: pipe(string(), minLength(1, 'API key is required')), model: optional(string()) }); diff --git a/web/src/lib/server/db/schema.ts b/web/src/lib/server/db/schema.ts index 569c937..9fc1f4b 100644 --- a/web/src/lib/server/db/schema.ts +++ b/web/src/lib/server/db/schema.ts @@ -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() diff --git a/web/src/lib/toast.ts b/web/src/lib/toast.ts new file mode 100644 index 0000000..f08911e --- /dev/null +++ b/web/src/lib/toast.ts @@ -0,0 +1,46 @@ +import { toast as sonnerToast } from 'svelte-sonner'; +import IconToast from '$lib/components/IconToast.svelte'; + +const toastDefaults = { + unstyled: true, + classes: { + toast: 'flex items-center gap-3 bg-neutral-900 text-white px-4 py-3 rounded-xl shadow-lg min-w-[300px]', + title: 'text-sm font-medium', + description: 'text-xs text-neutral-400' + } +} as const; + +export const toast = { + success(message: string, description?: string) { + sonnerToast(message, { + ...toastDefaults, + description, + icon: IconToast, + componentProps: { icon: 'ph:check-circle-duotone', class: 'h-5 w-5 text-emerald-400' } + }); + }, + error(message: string, description?: string) { + sonnerToast(message, { + ...toastDefaults, + description, + icon: IconToast, + componentProps: { icon: 'ph:x-circle-duotone', class: 'h-5 w-5 text-red-400' } + }); + }, + info(message: string, description?: string) { + sonnerToast(message, { + ...toastDefaults, + description, + icon: IconToast, + componentProps: { icon: 'ph:info-duotone', class: 'h-5 w-5 text-blue-400' } + }); + }, + warning(message: string, description?: string) { + sonnerToast(message, { + ...toastDefaults, + description, + icon: IconToast, + componentProps: { icon: 'ph:warning-duotone', class: 'h-5 w-5 text-amber-400' } + }); + } +}; diff --git a/web/src/routes/dashboard/+layout.server.ts b/web/src/routes/dashboard/+layout.server.ts index a5d3df6..49d88f0 100644 --- a/web/src/routes/dashboard/+layout.server.ts +++ b/web/src/routes/dashboard/+layout.server.ts @@ -1,12 +1,38 @@ import { redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { user as userTable } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = async ({ locals }) => { +export const load: LayoutServerLoad = async ({ locals, url }) => { if (!locals.user) { - redirect(307, '/login'); + redirect(307, `/login?redirect=${encodeURIComponent(url.pathname)}`); } + + // Check plan status (skip for the activate page itself) + if (!url.pathname.startsWith('/dashboard/activate')) { + const rows = await db + .select({ plan: userTable.plan, polarLicenseKey: userTable.polarLicenseKey }) + .from(userTable) + .where(eq(userTable.id, locals.user.id)) + .limit(1); + + if (!rows[0]?.plan) { + redirect(307, '/dashboard/activate'); + } + + return { + user: locals.user, + sessionToken: locals.session?.token ?? '', + plan: rows[0].plan, + licenseKey: rows[0].polarLicenseKey + }; + } + return { user: locals.user, - sessionToken: locals.session?.token ?? '' + sessionToken: locals.session?.token ?? '', + plan: null, + licenseKey: null }; }; diff --git a/web/src/routes/dashboard/+layout.svelte b/web/src/routes/dashboard/+layout.svelte index 5ab677b..a15d8f0 100644 --- a/web/src/routes/dashboard/+layout.svelte +++ b/web/src/routes/dashboard/+layout.svelte @@ -2,9 +2,24 @@ import { signout } from '$lib/api/auth.remote'; import { dashboardWs } from '$lib/stores/dashboard-ws.svelte'; import { onMount } from 'svelte'; + import { page } from '$app/state'; + import Icon from '@iconify/svelte'; + import { Toaster } from 'svelte-sonner'; let { children, data } = $props(); + const navItems = [ + { href: '/dashboard', label: 'Overview', icon: 'ph:squares-four-duotone', exact: true }, + { href: '/dashboard/devices', label: 'Devices', icon: 'ph:device-mobile-duotone' }, + { href: '/dashboard/api-keys', label: 'API Keys', icon: 'ph:key-duotone' }, + { href: '/dashboard/settings', label: 'Settings', icon: 'ph:gear-duotone' } + ]; + + function isActive(href: string, exact: boolean = false) { + if (exact) return page.url.pathname === href; + return page.url.pathname.startsWith(href); + } + onMount(() => { if (data.sessionToken) { dashboardWs.connect(data.sessionToken); @@ -15,17 +30,39 @@