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": { "dependencies": {
"@droidclaw/shared": "workspace:*", "@droidclaw/shared": "workspace:*",
"hono": "^4.7.0", "@polar-sh/sdk": "^0.43.1",
"better-auth": "^1.3.27", "better-auth": "^1.3.27",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"hono": "^4.7.0",
"postgres": "^3.4.7" "postgres": "^3.4.7"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -3,6 +3,9 @@ export const env = {
PORT: parseInt(process.env.PORT || "8080"), PORT: parseInt(process.env.PORT || "8080"),
CORS_ORIGIN: process.env.CORS_ORIGIN || "http://localhost:5173", CORS_ORIGIN: process.env.CORS_ORIGIN || "http://localhost:5173",
INTERNAL_SECRET: process.env.INTERNAL_SECRET || "", 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) { if (!env.DATABASE_URL) {

View File

@@ -11,6 +11,7 @@ import type { WebSocketData } from "./ws/sessions.js";
import { devices } from "./routes/devices.js"; import { devices } from "./routes/devices.js";
import { goals } from "./routes/goals.js"; import { goals } from "./routes/goals.js";
import { health } from "./routes/health.js"; import { health } from "./routes/health.js";
import { license } from "./routes/license.js";
const app = new Hono(); const app = new Hono();
@@ -34,6 +35,7 @@ app.on(["POST", "GET"], "/api/auth/*", (c) => {
app.route("/devices", devices); app.route("/devices", devices);
app.route("/goals", goals); app.route("/goals", goals);
app.route("/health", health); app.route("/health", health);
app.route("/license", license);
// Start server with WebSocket support // Start server with WebSocket support
const server = Bun.serve<WebSocketData>({ 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(), email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(), emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"), image: text("image"),
plan: text("plan"),
polarLicenseKey: text("polar_license_key"),
polarCustomerId: text("polar_customer_id"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at") updatedAt: timestamp("updated_at")
.defaultNow() .defaultNow()

View File

@@ -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;

View File

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

View File

@@ -22,6 +22,13 @@
"when": 1771326794177, "when": 1771326794177,
"tag": "0002_fearless_greymalkin", "tag": "0002_fearless_greymalkin",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1771429350614,
"tag": "0003_familiar_green_goblin",
"breakpoints": true
} }
] ]
} }

View File

@@ -52,8 +52,13 @@
"vitest-browser-svelte": "^1.1.0" "vitest-browser-svelte": "^1.1.0"
}, },
"dependencies": { "dependencies": {
"@iconify/svelte": "^5.2.1",
"add": "^2.0.6",
"better-auth": "^1.3.27", "better-auth": "^1.3.27",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"shadcn-svelte": "^1.1.1",
"sonner": "^2.0.7",
"svelte-sonner": "^1.0.7",
"valibot": "^1.1.0" "valibot": "^1.1.0"
} }
} }

View File

@@ -1,3 +1,11 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin '@tailwindcss/forms'; @plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography'; @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;
}

View File

@@ -10,7 +10,7 @@ export const listKeys = query(async () => {
export const createKey = form(createKeySchema, async ({ name }) => { export const createKey = form(createKeySchema, async ({ name }) => {
const { request } = getRequestEvent(); const { request } = getRequestEvent();
const result = await auth.api.createApiKey({ const result = await auth.api.createApiKey({
body: { name, prefix: 'dc' }, body: { name, prefix: 'droidclaw_' },
headers: request.headers headers: request.headers
}); });
return result; return result;

View File

@@ -9,9 +9,10 @@ export const signup = form(signupSchema, async (user) => {
}); });
export const login = form(loginSchema, 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 }); 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 () => { export const signout = form(async () => {

View File

@@ -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');
}
});

View File

@@ -51,4 +51,6 @@ export const updateConfig = form(llmConfigSchema, async (data) => {
model: data.model ?? null model: data.model ?? null
}); });
} }
return { saved: true };
}); });

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import Icon from '@iconify/svelte';
let { icon, class: className = '' }: { icon: string; class?: string } = $props();
</script>
<Icon {icon} class={className} />

View File

@@ -1,9 +1,9 @@
import { object, string, pipe, minLength } from 'valibot'; import { object, string, pipe, minLength } from 'valibot';
export const createKeySchema = object({ 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({ export const deleteKeySchema = object({
keyId: pipe(string(), minLength(1)) keyId: pipe(string(), minLength(1, 'Key ID is required'))
}); });

View File

@@ -1,12 +1,12 @@
import { object, string, pipe, minLength, email } from 'valibot'; import { object, string, pipe, minLength, email } from 'valibot';
export const signupSchema = object({ export const signupSchema = object({
name: pipe(string(), minLength(4)), name: pipe(string(), minLength(4, 'Name must be at least 4 characters')),
email: pipe(string(), minLength(1), email()), email: pipe(string(), minLength(1, 'Email is required'), email('Please enter a valid email')),
password: pipe(string(), minLength(8)) password: pipe(string(), minLength(8, 'Password must be at least 8 characters'))
}); });
export const loginSchema = object({ export const loginSchema = object({
email: pipe(string(), minLength(1), email()), email: pipe(string(), minLength(1, 'Email is required'), email('Please enter a valid email')),
password: pipe(string(), minLength(8)) password: pipe(string(), minLength(8, 'Password must be at least 8 characters'))
}); });

View File

@@ -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'))
});

View File

@@ -1,7 +1,7 @@
import { object, string, pipe, minLength, optional } from 'valibot'; import { object, string, pipe, minLength, optional } from 'valibot';
export const llmConfigSchema = object({ export const llmConfigSchema = object({
provider: pipe(string(), minLength(1)), provider: pipe(string(), minLength(1, 'Please select a provider')),
apiKey: pipe(string(), minLength(1)), apiKey: pipe(string(), minLength(1, 'API key is required')),
model: optional(string()) model: optional(string())
}); });

View File

@@ -6,6 +6,9 @@ export const user = pgTable('user', {
email: text('email').notNull().unique(), email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(), emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'), image: text('image'),
plan: text('plan'),
polarLicenseKey: text('polar_license_key'),
polarCustomerId: text('polar_customer_id'),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at') updatedAt: timestamp('updated_at')
.defaultNow() .defaultNow()

46
web/src/lib/toast.ts Normal file
View File

@@ -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' }
});
}
};

View File

@@ -1,12 +1,38 @@
import { redirect } from '@sveltejs/kit'; 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'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => { export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!locals.user) { 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 { return {
user: locals.user, user: locals.user,
sessionToken: locals.session?.token ?? '' sessionToken: locals.session?.token ?? '',
plan: rows[0].plan,
licenseKey: rows[0].polarLicenseKey
};
}
return {
user: locals.user,
sessionToken: locals.session?.token ?? '',
plan: null,
licenseKey: null
}; };
}; };

View File

@@ -2,9 +2,24 @@
import { signout } from '$lib/api/auth.remote'; import { signout } from '$lib/api/auth.remote';
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte'; import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/state';
import Icon from '@iconify/svelte';
import { Toaster } from 'svelte-sonner';
let { children, data } = $props(); 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(() => { onMount(() => {
if (data.sessionToken) { if (data.sessionToken) {
dashboardWs.connect(data.sessionToken); dashboardWs.connect(data.sessionToken);
@@ -15,17 +30,39 @@
<div class="flex min-h-screen"> <div class="flex min-h-screen">
<aside class="flex w-64 flex-col border-r border-neutral-200 bg-neutral-50 p-6"> <aside class="flex w-64 flex-col border-r border-neutral-200 bg-neutral-50 p-6">
<h1 class="mb-8 text-xl font-bold">DroidClaw</h1> <div class="mb-8">
<nav class="flex flex-col gap-2"> <h1 class="text-lg font-bold tracking-tight">DroidClaw</h1>
<a href="/dashboard" class="rounded px-3 py-2 hover:bg-neutral-200">Overview</a> </div>
<a href="/dashboard/devices" class="rounded px-3 py-2 hover:bg-neutral-200">Devices</a> <nav class="flex flex-col gap-1">
<a href="/dashboard/api-keys" class="rounded px-3 py-2 hover:bg-neutral-200">API Keys</a> {#each navItems as item}
<a href="/dashboard/settings" class="rounded px-3 py-2 hover:bg-neutral-200">Settings</a> <a
href={item.href}
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
{isActive(item.href, item.exact)
? 'bg-neutral-200/70 text-neutral-900'
: 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700'}"
>
<Icon
icon={item.icon}
class="h-5 w-5 {isActive(item.href, item.exact) ? 'text-neutral-700' : 'text-neutral-400'}"
/>
{item.label}
</a>
{/each}
</nav> </nav>
<div class="mt-auto pt-8"> <div class="mt-auto pt-8">
<p class="mb-2 text-sm text-neutral-500">{data.user.email}</p> {#if data.plan}
<div class="mb-3 flex items-center gap-2 rounded-lg bg-emerald-50 px-3 py-2">
<Icon icon="ph:seal-check-duotone" class="h-4 w-4 text-emerald-600" />
<span class="text-xs font-semibold uppercase tracking-wide text-emerald-700">{data.plan === 'ltd' ? 'Lifetime' : data.plan}</span>
</div>
{/if}
<form {...signout}> <form {...signout}>
<button type="submit" class="text-sm text-neutral-500 hover:text-neutral-800"> <button
type="submit"
class="mt-1 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
>
<Icon icon="ph:sign-out-duotone" class="h-5 w-5" />
Sign out Sign out
</button> </button>
</form> </form>
@@ -36,3 +73,5 @@
{@render children?.()} {@render children?.()}
</main> </main>
</div> </div>
<Toaster position="bottom-right" />

View File

@@ -1,30 +1,63 @@
<script lang="ts"> <script lang="ts">
import Icon from '@iconify/svelte';
let { data } = $props(); let { data } = $props();
const cards = [
{
href: '/dashboard/devices',
icon: 'ph:device-mobile-duotone',
title: 'Devices',
desc: 'Manage connected phones',
color: 'text-green-600 bg-green-50'
},
{
href: '/dashboard/api-keys',
icon: 'ph:key-duotone',
title: 'API Keys',
desc: 'Create keys for your devices',
color: 'text-amber-600 bg-amber-50'
},
{
href: '/dashboard/settings',
icon: 'ph:gear-duotone',
title: 'Settings',
desc: 'Configure LLM provider',
color: 'text-blue-600 bg-blue-50'
}
];
</script> </script>
<h2 class="mb-6 text-2xl font-bold">Dashboard</h2> <h2 class="mb-1 text-2xl font-bold">Dashboard</h2>
<p class="text-neutral-600">Welcome back, {data.user.name}.</p> <p class="mb-8 text-neutral-500">Welcome back, {data.user.name}.</p>
<div class="mt-8 grid grid-cols-3 gap-6"> {#if data.plan}
<a <div class="mb-8 flex items-center gap-4 rounded-xl border border-emerald-200 bg-emerald-50 p-5">
href="/dashboard/devices" <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100">
class="rounded-lg border border-neutral-200 p-6 hover:border-neutral-400" <Icon icon="ph:seal-check-duotone" class="h-5 w-5 text-emerald-600" />
> </div>
<h3 class="font-semibold">Devices</h3> <div>
<p class="mt-1 text-sm text-neutral-500">Manage connected phones</p> <h3 class="font-semibold text-emerald-900">{data.plan === 'ltd' ? 'Lifetime Deal' : data.plan} Plan</h3>
</a> <p class="mt-0.5 text-sm text-emerald-700">
<a License: {data.licenseKey ?? 'Active'}
href="/dashboard/api-keys" </p>
class="rounded-lg border border-neutral-200 p-6 hover:border-neutral-400" </div>
> </div>
<h3 class="font-semibold">API Keys</h3> {/if}
<p class="mt-1 text-sm text-neutral-500">Create keys for your devices</p>
</a> <div class="grid grid-cols-3 gap-5">
<a {#each cards as card}
href="/dashboard/settings" <a
class="rounded-lg border border-neutral-200 p-6 hover:border-neutral-400" href={card.href}
> class="group flex items-start gap-4 rounded-xl border border-neutral-200 p-5 transition-all hover:border-neutral-300 hover:shadow-sm"
<h3 class="font-semibold">Settings</h3> >
<p class="mt-1 text-sm text-neutral-500">Configure LLM provider</p> <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg {card.color}">
</a> <Icon icon={card.icon} class="h-5 w-5" />
</div>
<div>
<h3 class="font-semibold text-neutral-900">{card.title}</h3>
<p class="mt-0.5 text-sm text-neutral-500">{card.desc}</p>
</div>
</a>
{/each}
</div> </div>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { activateLicense, activateFromCheckout } from '$lib/api/license.remote';
import { page } from '$app/state';
import Icon from '@iconify/svelte';
const checkoutId = page.url.searchParams.get('checkout_id');
</script>
{#if checkoutId}
<!-- Auto-activate from Polar checkout -->
<div class="mx-auto max-w-md pt-20">
<div class="mb-8 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100">
<Icon icon="ph:spinner-duotone" class="h-6 w-6 animate-spin text-neutral-600" />
</div>
<h2 class="text-2xl font-bold">Activating your license...</h2>
<p class="mt-1 text-neutral-500">
We're setting up your account. This will only take a moment.
</p>
</div>
<form {...activateFromCheckout} class="space-y-4">
<input type="hidden" {...activateFromCheckout.fields.checkoutId.as('text')} value={checkoutId} />
<button
type="submit"
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
>
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
Activate Now
</button>
</form>
<div class="mt-6 rounded-xl border border-neutral-200 bg-neutral-50 p-4">
<p class="text-center text-sm text-neutral-500">
If activation doesn't work automatically, check your email for the license key and enter it manually below.
</p>
</div>
<div class="mt-8 mb-4 text-center">
<p class="text-sm font-medium text-neutral-400">Or activate manually</p>
</div>
<form {...activateLicense} class="space-y-4">
<label class="block">
<span class="flex items-center gap-1.5 text-sm font-medium text-neutral-700">
<Icon icon="ph:key-duotone" class="h-4 w-4 text-neutral-400" />
License Key
</span>
<input
{...activateLicense.fields.key.as('text')}
placeholder="DROIDCLAW-XXXX-XXXX-XXXX"
class="mt-1 block w-full rounded-xl border border-neutral-300 px-4 py-2.5 focus:border-neutral-900 focus:outline-none"
/>
{#each activateLicense.fields.key.issues() ?? [] as issue (issue.message)}
<p class="mt-1 text-sm text-red-600">{issue.message}</p>
{/each}
</label>
<button
type="submit"
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
>
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
Activate
</button>
</form>
</div>
{:else}
<!-- Manual activation (no checkout ID) -->
<div class="mx-auto max-w-md pt-20">
<div class="mb-8 text-center">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100">
<Icon icon="ph:seal-check-duotone" class="h-6 w-6 text-neutral-600" />
</div>
<h2 class="text-2xl font-bold">Activate your license</h2>
<p class="mt-1 text-neutral-500">
Enter the license key you received after purchasing DroidClaw.
</p>
</div>
<form {...activateLicense} class="space-y-4">
<label class="block">
<span class="flex items-center gap-1.5 text-sm font-medium text-neutral-700">
<Icon icon="ph:key-duotone" class="h-4 w-4 text-neutral-400" />
License Key
</span>
<input
{...activateLicense.fields.key.as('text')}
placeholder="DROIDCLAW-XXXX-XXXX-XXXX"
class="mt-1 block w-full rounded-xl border border-neutral-300 px-4 py-2.5 focus:border-neutral-900 focus:outline-none"
/>
{#each activateLicense.fields.key.issues() ?? [] as issue (issue.message)}
<p class="mt-1 text-sm text-red-600">{issue.message}</p>
{/each}
</label>
<button
type="submit"
class="flex w-full items-center justify-center gap-2 rounded-xl bg-neutral-900 px-4 py-2.5 font-medium text-white hover:bg-neutral-800"
>
<Icon icon="ph:seal-check-duotone" class="h-4 w-4" />
Activate
</button>
</form>
<p class="mt-6 text-center text-sm text-neutral-400">
Don't have a key?
<a href="https://sandbox-api.polar.sh/v1/checkout-links/polar_cl_5pGavRIJJhM8ge6p0UaeaadT2bCiqL04CYXgW3bwVac/redirect" class="font-medium text-neutral-700 underline hover:text-neutral-900">
Purchase here
</a>
</p>
</div>
{/if}

View File

@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { listKeys, createKey, deleteKey } from '$lib/api/api-keys.remote'; import { listKeys, createKey, deleteKey } from '$lib/api/api-keys.remote';
import Icon from '@iconify/svelte';
import { toast } from '$lib/toast';
let newKeyValue = $state<string | null>(null); let newKeyValue = $state<string | null>(null);
let keysPromise = $state(listKeys()); let keysPromise = $state(listKeys());
@@ -8,12 +10,14 @@
if (createKey.result?.key) { if (createKey.result?.key) {
newKeyValue = createKey.result.key; newKeyValue = createKey.result.key;
keysPromise = listKeys(); keysPromise = listKeys();
toast.success('API key created');
} }
}); });
$effect(() => { $effect(() => {
if (deleteKey.result?.deleted) { if (deleteKey.result?.deleted) {
keysPromise = listKeys(); keysPromise = listKeys();
toast.success('API key deleted');
} }
}); });
</script> </script>
@@ -21,15 +25,18 @@
<h2 class="mb-6 text-2xl font-bold">API Keys</h2> <h2 class="mb-6 text-2xl font-bold">API Keys</h2>
<!-- Create new key --> <!-- Create new key -->
<div class="mb-8 rounded-lg border border-neutral-200 p-6"> <div class="mb-8 rounded-xl border border-neutral-200 p-6">
<h3 class="mb-4 font-semibold">Create New Key</h3> <div class="mb-4 flex items-center gap-2">
<Icon icon="ph:plus-circle-duotone" class="h-5 w-5 text-neutral-500" />
<h3 class="font-semibold">Create New Key</h3>
</div>
<form {...createKey} class="flex items-end gap-4"> <form {...createKey} class="flex items-end gap-4">
<label class="flex flex-1 flex-col gap-1"> <label class="flex flex-1 flex-col gap-1">
<span class="text-sm text-neutral-600">Key Name</span> <span class="text-sm text-neutral-600">Key Name</span>
<input <input
{...createKey.fields.name.as('text')} {...createKey.fields.name.as('text')}
placeholder="e.g. Production, Development" placeholder="e.g. Production, Development"
class="rounded border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none" class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
/> />
{#each createKey.fields.name.issues() ?? [] as issue (issue.message)} {#each createKey.fields.name.issues() ?? [] as issue (issue.message)}
<p class="text-sm text-red-600">{issue.message}</p> <p class="text-sm text-red-600">{issue.message}</p>
@@ -37,8 +44,9 @@
</label> </label>
<button <button
type="submit" type="submit"
class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700" class="flex items-center gap-2 rounded-lg bg-neutral-800 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700"
> >
<Icon icon="ph:plus-duotone" class="h-4 w-4" />
Create Create
</button> </button>
</form> </form>
@@ -46,21 +54,26 @@
<!-- Newly created key warning --> <!-- Newly created key warning -->
{#if newKeyValue} {#if newKeyValue}
<div class="mb-8 rounded-lg border border-yellow-300 bg-yellow-50 p-6"> <div class="mb-8 rounded-xl border border-yellow-300 bg-yellow-50 p-6">
<h3 class="mb-2 font-semibold text-yellow-800">Save Your API Key</h3> <div class="mb-2 flex items-center gap-2">
<Icon icon="ph:warning-duotone" class="h-5 w-5 text-yellow-700" />
<h3 class="font-semibold text-yellow-800">Save Your API Key</h3>
</div>
<p class="mb-3 text-sm text-yellow-700"> <p class="mb-3 text-sm text-yellow-700">
Copy this key now. It will not be shown again. Copy this key now. It will not be shown again.
</p> </p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<code class="flex-1 rounded bg-yellow-100 px-3 py-2 text-sm font-mono break-all"> <code class="flex-1 rounded-lg bg-yellow-100 px-3 py-2 font-mono text-sm break-all">
{newKeyValue} {newKeyValue}
</code> </code>
<button <button
onclick={() => { onclick={() => {
navigator.clipboard.writeText(newKeyValue!); navigator.clipboard.writeText(newKeyValue!);
toast.success('Copied to clipboard');
}} }}
class="rounded border border-yellow-400 px-3 py-2 text-sm text-yellow-800 hover:bg-yellow-100" class="flex items-center gap-1.5 rounded-lg border border-yellow-400 px-3 py-2 text-sm font-medium text-yellow-800 hover:bg-yellow-100"
> >
<Icon icon="ph:copy-duotone" class="h-4 w-4" />
Copy Copy
</button> </button>
</div> </div>
@@ -74,21 +87,29 @@
{/if} {/if}
<!-- Existing keys list --> <!-- Existing keys list -->
<div class="rounded-lg border border-neutral-200"> <div class="rounded-xl border border-neutral-200">
<div class="border-b border-neutral-200 px-6 py-4"> <div class="flex items-center gap-2 border-b border-neutral-200 px-6 py-4">
<Icon icon="ph:key-duotone" class="h-5 w-5 text-neutral-400" />
<h3 class="font-semibold">Your Keys</h3> <h3 class="font-semibold">Your Keys</h3>
</div> </div>
{#await keysPromise} {#await keysPromise}
<div class="px-6 py-8 text-center text-sm text-neutral-500">Loading keys...</div> <div class="flex items-center justify-center gap-2 px-6 py-8 text-sm text-neutral-500">
<Icon icon="ph:circle-notch-duotone" class="h-5 w-5 animate-spin text-neutral-400" />
Loading keys...
</div>
{:then keys} {:then keys}
{#if keys && keys.length > 0} {#if keys && keys.length > 0}
<ul class="divide-y divide-neutral-100"> <ul class="divide-y divide-neutral-100">
{#each keys as key (key.id)} {#each keys as key (key.id)}
<li class="flex items-center justify-between px-6 py-4"> <li class="flex items-center justify-between px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-neutral-100">
<Icon icon="ph:key-duotone" class="h-4 w-4 text-neutral-400" />
</div>
<div> <div>
<p class="font-medium">{key.name ?? 'Unnamed Key'}</p> <p class="font-medium">{key.name ?? 'Unnamed Key'}</p>
<div class="mt-1 flex items-center gap-3 text-sm text-neutral-500"> <div class="mt-0.5 flex items-center gap-3 text-sm text-neutral-500">
{#if key.start} {#if key.start}
<span class="font-mono">{key.start}...</span> <span class="font-mono">{key.start}...</span>
{/if} {/if}
@@ -97,12 +118,14 @@
</span> </span>
</div> </div>
</div> </div>
</div>
<form {...deleteKey}> <form {...deleteKey}>
<input type="hidden" name="keyId" value={key.id} /> <input type="hidden" name="keyId" value={key.id} />
<button <button
type="submit" type="submit"
class="rounded border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50" class="flex items-center gap-1.5 rounded-lg border border-red-200 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
> >
<Icon icon="ph:trash-duotone" class="h-4 w-4" />
Delete Delete
</button> </button>
</form> </form>
@@ -110,12 +133,14 @@
{/each} {/each}
</ul> </ul>
{:else} {:else}
<div class="px-6 py-8 text-center text-sm text-neutral-500"> <div class="px-6 py-10 text-center">
No API keys yet. Create one above. <Icon icon="ph:key-duotone" class="mx-auto mb-3 h-8 w-8 text-neutral-300" />
<p class="text-sm text-neutral-500">No API keys yet. Create one above.</p>
</div> </div>
{/if} {/if}
{:catch} {:catch}
<div class="px-6 py-8 text-center text-sm text-red-600"> <div class="flex items-center justify-center gap-2 px-6 py-8 text-sm text-red-600">
<Icon icon="ph:warning-duotone" class="h-5 w-5" />
Failed to load keys. Please try again. Failed to load keys. Please try again.
</div> </div>
{/await} {/await}

View File

@@ -3,6 +3,8 @@
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte'; import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import DeviceCard from '$lib/components/DeviceCard.svelte'; import DeviceCard from '$lib/components/DeviceCard.svelte';
import Icon from '@iconify/svelte';
import { toast } from '$lib/toast';
interface DeviceEntry { interface DeviceEntry {
deviceId: string; deviceId: string;
@@ -67,12 +69,14 @@
...devices ...devices
]; ];
} }
toast.success(`${name} connected`);
} else if (msg.type === 'device_offline') { } else if (msg.type === 'device_offline') {
const id = msg.deviceId as string; const id = msg.deviceId as string;
const existing = devices.find((d) => d.deviceId === id); const existing = devices.find((d) => d.deviceId === id);
if (existing) { if (existing) {
existing.status = 'offline'; existing.status = 'offline';
devices = [...devices]; devices = [...devices];
toast.info(`${existing.name} disconnected`);
} }
} else if (msg.type === 'device_status') { } else if (msg.type === 'device_status') {
const id = msg.deviceId as string; const id = msg.deviceId as string;
@@ -91,12 +95,19 @@
<h2 class="mb-6 text-2xl font-bold">Devices</h2> <h2 class="mb-6 text-2xl font-bold">Devices</h2>
{#if devices.length === 0} {#if devices.length === 0}
<div class="rounded-lg border border-neutral-200 p-8 text-center"> <div class="rounded-xl border border-neutral-200 p-10 text-center">
<p class="text-neutral-500">No devices connected.</p> <div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100">
<p class="mt-2 text-sm text-neutral-400"> <Icon icon="ph:device-mobile-slash-duotone" class="h-6 w-6 text-neutral-400" />
</div>
<p class="font-medium text-neutral-600">No devices connected</p>
<p class="mt-1 text-sm text-neutral-400">
Install the Android app, paste your API key, and your device will appear here. Install the Android app, paste your API key, and your device will appear here.
</p> </p>
<a href="/dashboard/api-keys" class="mt-4 inline-block text-sm text-blue-600 hover:underline"> <a
href="/dashboard/api-keys"
class="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-neutral-700 hover:text-neutral-900"
>
<Icon icon="ph:key-duotone" class="h-4 w-4" />
Create an API key Create an API key
</a> </a>
</div> </div>

View File

@@ -1,20 +1,64 @@
<script lang="ts"> <script lang="ts">
import { getConfig, updateConfig } from '$lib/api/settings.remote'; import { getConfig, updateConfig } from '$lib/api/settings.remote';
import { page } from '$app/state';
import Icon from '@iconify/svelte';
import { toast } from '$lib/toast';
const config = await getConfig(); const config = await getConfig();
const layoutData = page.data;
$effect(() => {
if (updateConfig.result?.saved) {
toast.success('Settings saved');
}
});
</script> </script>
<h2 class="mb-6 text-2xl font-bold">Settings</h2> <h2 class="mb-6 text-2xl font-bold">Settings</h2>
<div class="max-w-lg rounded-lg border border-neutral-200 p-6"> <div class="mb-6 max-w-lg rounded-xl border border-neutral-200 p-6">
<h3 class="mb-4 font-semibold">LLM Provider</h3> <div class="mb-4 flex items-center gap-2">
<Icon icon="ph:user-duotone" class="h-5 w-5 text-neutral-500" />
<h3 class="font-semibold">Account</h3>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-neutral-500">Email</span>
<span class="text-sm font-medium text-neutral-900 blur-sm transition-all duration-200 hover:blur-none">{layoutData.user.email}</span>
</div>
{#if layoutData.plan}
<div class="flex items-center justify-between">
<span class="text-sm text-neutral-500">Plan</span>
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
<Icon icon="ph:seal-check-duotone" class="h-3.5 w-3.5" />
{layoutData.plan === 'ltd' ? 'Lifetime' : layoutData.plan}
</span>
</div>
{/if}
{#if layoutData.licenseKey}
<div class="flex items-center justify-between">
<span class="text-sm text-neutral-500">License</span>
<span class="font-mono text-sm text-neutral-600">{layoutData.licenseKey}</span>
</div>
{/if}
</div>
</div>
<div class="max-w-lg rounded-xl border border-neutral-200 p-6">
<div class="mb-4 flex items-center gap-2">
<Icon icon="ph:brain-duotone" class="h-5 w-5 text-neutral-500" />
<h3 class="font-semibold">LLM Provider</h3>
</div>
<form {...updateConfig} class="space-y-4"> <form {...updateConfig} class="space-y-4">
<label class="block"> <label class="block">
<span class="text-sm text-neutral-600">Provider</span> <span class="flex items-center gap-1.5 text-sm text-neutral-600">
<Icon icon="ph:plugs-connected-duotone" class="h-4 w-4 text-neutral-400" />
Provider
</span>
<select <select
{...updateConfig.fields.provider.as('text')} {...updateConfig.fields.provider.as('text')}
class="mt-1 block w-full rounded border border-neutral-300 px-3 py-2 text-sm" class="mt-1 block w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm"
> >
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="groq">Groq</option> <option value="groq">Groq</option>
@@ -28,11 +72,14 @@
</label> </label>
<label class="block"> <label class="block">
<span class="text-sm text-neutral-600">API Key</span> <span class="flex items-center gap-1.5 text-sm text-neutral-600">
<Icon icon="ph:lock-key-duotone" class="h-4 w-4 text-neutral-400" />
API Key
</span>
<input <input
{...updateConfig.fields.apiKey.as('password')} {...updateConfig.fields.apiKey.as('password')}
placeholder={config?.apiKey ?? 'Enter your API key'} placeholder={config?.apiKey ?? 'Enter your API key'}
class="mt-1 block w-full rounded border border-neutral-300 px-3 py-2 text-sm" class="mt-1 block w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm"
/> />
{#each updateConfig.fields.apiKey.issues() ?? [] as issue (issue.message)} {#each updateConfig.fields.apiKey.issues() ?? [] as issue (issue.message)}
<p class="text-sm text-red-600">{issue.message}</p> <p class="text-sm text-red-600">{issue.message}</p>
@@ -40,23 +87,31 @@
</label> </label>
<label class="block"> <label class="block">
<span class="text-sm text-neutral-600">Model (optional)</span> <span class="flex items-center gap-1.5 text-sm text-neutral-600">
<Icon icon="ph:cube-duotone" class="h-4 w-4 text-neutral-400" />
Model (optional)
</span>
<input <input
{...updateConfig.fields.model.as('text')} {...updateConfig.fields.model.as('text')}
placeholder="e.g., gpt-4o, llama-3.3-70b-versatile" placeholder="e.g., gpt-4o, llama-3.3-70b-versatile"
class="mt-1 block w-full rounded border border-neutral-300 px-3 py-2 text-sm" class="mt-1 block w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm"
/> />
</label> </label>
<button type="submit" class="rounded bg-neutral-800 px-4 py-2 text-sm text-white hover:bg-neutral-700"> <button
type="submit"
class="flex items-center gap-2 rounded-lg bg-neutral-800 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700"
>
<Icon icon="ph:floppy-disk-duotone" class="h-4 w-4" />
Save Save
</button> </button>
</form> </form>
{#if config} {#if config}
<p class="mt-4 text-sm text-neutral-500"> <div class="mt-4 flex items-center gap-2 rounded-lg bg-neutral-50 px-3 py-2 text-sm text-neutral-500">
<Icon icon="ph:info-duotone" class="h-4 w-4 shrink-0 text-neutral-400" />
Current: {config.provider} &middot; Key: {config.apiKey} Current: {config.provider} &middot; Key: {config.apiKey}
{#if config.model} &middot; Model: {config.model}{/if} {#if config.model} &middot; Model: {config.model}{/if}
</p> </div>
{/if} {/if}
</div> </div>