feat: Polar license key integration and dashboard UI improvements
- Add license activation flow: server validates/activates keys via Polar SDK - Auto-activate from Polar checkout redirect (checkout_id in URL) - Gate dashboard behind license activation (redirect to /activate if no plan) - Preserve redirect URL through login flow for post-purchase activation - Show plan status badge in sidebar and overview page - Add account section to settings (email, plan, license key) - Add svelte-sonner toasts with custom black theme and iconify icons - Improve validation messages across all forms - Update API key prefix to droidclaw_ - Add cursor pointer globally for clickable elements - Support Polar sandbox mode via POLAR_SANDBOX env flag
This commit is contained in:
@@ -9,9 +9,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@droidclaw/shared": "workspace:*",
|
||||
"hono": "^4.7.0",
|
||||
"@polar-sh/sdk": "^0.43.1",
|
||||
"better-auth": "^1.3.27",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"hono": "^4.7.0",
|
||||
"postgres": "^3.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,6 +3,9 @@ export const env = {
|
||||
PORT: parseInt(process.env.PORT || "8080"),
|
||||
CORS_ORIGIN: process.env.CORS_ORIGIN || "http://localhost:5173",
|
||||
INTERNAL_SECRET: process.env.INTERNAL_SECRET || "",
|
||||
POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN || "",
|
||||
POLAR_ORGANIZATION_ID: process.env.POLAR_ORGANIZATION_ID || "",
|
||||
POLAR_SANDBOX: process.env.POLAR_SANDBOX || "false",
|
||||
};
|
||||
|
||||
if (!env.DATABASE_URL) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { WebSocketData } from "./ws/sessions.js";
|
||||
import { devices } from "./routes/devices.js";
|
||||
import { goals } from "./routes/goals.js";
|
||||
import { health } from "./routes/health.js";
|
||||
import { license } from "./routes/license.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -34,6 +35,7 @@ app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
||||
app.route("/devices", devices);
|
||||
app.route("/goals", goals);
|
||||
app.route("/health", health);
|
||||
app.route("/license", license);
|
||||
|
||||
// Start server with WebSocket support
|
||||
const server = Bun.serve<WebSocketData>({
|
||||
|
||||
186
server/src/routes/license.ts
Normal file
186
server/src/routes/license.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Hono } from "hono";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Polar } from "@polar-sh/sdk";
|
||||
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
|
||||
import { db } from "../db.js";
|
||||
import { user as userTable } from "../schema.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
const license = new Hono<AuthEnv>();
|
||||
license.use("*", sessionMiddleware);
|
||||
|
||||
const getPolar = () =>
|
||||
new Polar({
|
||||
accessToken: env.POLAR_ACCESS_TOKEN,
|
||||
server: env.POLAR_SANDBOX === "true" ? "sandbox" : "production",
|
||||
});
|
||||
|
||||
/** POST /license/activate — validate + activate a Polar license key */
|
||||
license.post("/activate", async (c) => {
|
||||
const currentUser = c.get("user");
|
||||
const { key } = await c.req.json<{ key: string }>();
|
||||
|
||||
if (!key) {
|
||||
return c.json({ error: "License key is required" }, 400);
|
||||
}
|
||||
|
||||
if (!env.POLAR_ACCESS_TOKEN || !env.POLAR_ORGANIZATION_ID) {
|
||||
return c.json({ error: "Payment system not configured" }, 500);
|
||||
}
|
||||
|
||||
// Check if user already has a plan
|
||||
const existing = await db
|
||||
.select({ plan: userTable.plan })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, currentUser.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]?.plan) {
|
||||
return c.json({ error: "Account already activated" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const polar = getPolar();
|
||||
|
||||
// Validate the license key with Polar
|
||||
const result = await polar.licenseKeys.validate({
|
||||
key,
|
||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||
});
|
||||
|
||||
if (result.status !== "granted") {
|
||||
return c.json({ error: "Invalid or revoked license key" }, 400);
|
||||
}
|
||||
|
||||
// Determine plan from benefit ID or default to "ltd"
|
||||
const plan = "ltd";
|
||||
|
||||
// Activate the key (tracks activation count on Polar's side)
|
||||
await polar.licenseKeys.activate({
|
||||
key,
|
||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||
label: `${currentUser.email}`,
|
||||
});
|
||||
|
||||
// Store on user record
|
||||
await db
|
||||
.update(userTable)
|
||||
.set({
|
||||
plan,
|
||||
polarLicenseKey: result.displayKey ?? key.slice(0, 8) + "...",
|
||||
polarCustomerId: (result as Record<string, unknown>).userId as string ?? null,
|
||||
})
|
||||
.where(eq(userTable.id, currentUser.id));
|
||||
|
||||
return c.json({ success: true, plan });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[License] Activation failed for ${currentUser.email}:`, message);
|
||||
|
||||
if (message.includes("not found") || message.includes("invalid")) {
|
||||
return c.json({ error: "Invalid license key" }, 400);
|
||||
}
|
||||
if (message.includes("limit")) {
|
||||
return c.json({ error: "License key activation limit reached" }, 400);
|
||||
}
|
||||
|
||||
return c.json({ error: "Failed to validate license key" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /license/activate-checkout — auto-activate from Polar checkout ID */
|
||||
license.post("/activate-checkout", async (c) => {
|
||||
const currentUser = c.get("user");
|
||||
const { checkoutId } = await c.req.json<{ checkoutId: string }>();
|
||||
|
||||
if (!checkoutId) {
|
||||
return c.json({ error: "Checkout ID is required" }, 400);
|
||||
}
|
||||
|
||||
if (!env.POLAR_ACCESS_TOKEN || !env.POLAR_ORGANIZATION_ID) {
|
||||
return c.json({ error: "Payment system not configured" }, 500);
|
||||
}
|
||||
|
||||
// Check if user already has a plan
|
||||
const existing = await db
|
||||
.select({ plan: userTable.plan })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, currentUser.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]?.plan) {
|
||||
return c.json({ error: "Account already activated" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const polar = getPolar();
|
||||
|
||||
// 1. Get checkout session to find the customer
|
||||
const checkout = await polar.checkouts.get({ id: checkoutId });
|
||||
|
||||
if (checkout.status !== "succeeded") {
|
||||
return c.json({ error: "Checkout not completed yet" }, 400);
|
||||
}
|
||||
|
||||
if (!checkout.customerId) {
|
||||
return c.json({ error: "No customer found for this checkout" }, 400);
|
||||
}
|
||||
|
||||
// 2. List license keys for the org and find one for this customer
|
||||
const keysPage = await polar.licenseKeys.list({
|
||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const customerKey = keysPage.result.items.find(
|
||||
(k) => k.customerId === checkout.customerId && k.status === "granted"
|
||||
);
|
||||
|
||||
if (!customerKey) {
|
||||
return c.json({ error: "No license key found for this purchase" }, 400);
|
||||
}
|
||||
|
||||
// 3. Activate the key
|
||||
await polar.licenseKeys.activate({
|
||||
key: customerKey.key,
|
||||
organizationId: env.POLAR_ORGANIZATION_ID,
|
||||
label: `${currentUser.email}`,
|
||||
});
|
||||
|
||||
// 4. Store on user record
|
||||
await db
|
||||
.update(userTable)
|
||||
.set({
|
||||
plan: "ltd",
|
||||
polarLicenseKey: customerKey.displayKey,
|
||||
polarCustomerId: checkout.customerId,
|
||||
})
|
||||
.where(eq(userTable.id, currentUser.id));
|
||||
|
||||
return c.json({ success: true, plan: "ltd" });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[License] Checkout activation failed for ${currentUser.email}:`, message);
|
||||
return c.json({ error: "Failed to activate from checkout" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /license/status — check current user's plan */
|
||||
license.get("/status", async (c) => {
|
||||
const currentUser = c.get("user");
|
||||
|
||||
const rows = await db
|
||||
.select({ plan: userTable.plan, polarLicenseKey: userTable.polarLicenseKey })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.id, currentUser.id))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
return c.json({
|
||||
activated: !!row?.plan,
|
||||
plan: row?.plan ?? null,
|
||||
licenseKey: row?.polarLicenseKey ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
export { license };
|
||||
@@ -6,6 +6,9 @@ export const user = pgTable("user", {
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
plan: text("plan"),
|
||||
polarLicenseKey: text("polar_license_key"),
|
||||
polarCustomerId: text("polar_customer_id"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
|
||||
3
web/drizzle/0003_familiar_green_goblin.sql
Normal file
3
web/drizzle/0003_familiar_green_goblin.sql
Normal 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;
|
||||
811
web/drizzle/meta/0003_snapshot.json
Normal file
811
web/drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
69
web/src/lib/api/license.remote.ts
Normal file
69
web/src/lib/api/license.remote.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
@@ -51,4 +51,6 @@ export const updateConfig = form(llmConfigSchema, async (data) => {
|
||||
model: data.model ?? null
|
||||
});
|
||||
}
|
||||
|
||||
return { saved: true };
|
||||
});
|
||||
|
||||
7
web/src/lib/components/IconToast.svelte
Normal file
7
web/src/lib/components/IconToast.svelte
Normal 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} />
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
9
web/src/lib/schema/license.ts
Normal file
9
web/src/lib/schema/license.ts
Normal 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'))
|
||||
});
|
||||
@@ -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())
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
46
web/src/lib/toast.ts
Normal file
46
web/src/lib/toast.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 ?? ''
|
||||
sessionToken: locals.session?.token ?? '',
|
||||
plan: rows[0].plan,
|
||||
licenseKey: rows[0].polarLicenseKey
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
sessionToken: locals.session?.token ?? '',
|
||||
plan: null,
|
||||
licenseKey: null
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<div class="flex min-h-screen">
|
||||
<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>
|
||||
<nav class="flex flex-col gap-2">
|
||||
<a href="/dashboard" class="rounded px-3 py-2 hover:bg-neutral-200">Overview</a>
|
||||
<a href="/dashboard/devices" class="rounded px-3 py-2 hover:bg-neutral-200">Devices</a>
|
||||
<a href="/dashboard/api-keys" class="rounded px-3 py-2 hover:bg-neutral-200">API Keys</a>
|
||||
<a href="/dashboard/settings" class="rounded px-3 py-2 hover:bg-neutral-200">Settings</a>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-lg font-bold tracking-tight">DroidClaw</h1>
|
||||
</div>
|
||||
<nav class="flex flex-col gap-1">
|
||||
{#each navItems as item}
|
||||
<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>
|
||||
<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}>
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
@@ -36,3 +73,5 @@
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
@@ -1,30 +1,63 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
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>
|
||||
|
||||
<h2 class="mb-6 text-2xl font-bold">Dashboard</h2>
|
||||
<p class="text-neutral-600">Welcome back, {data.user.name}.</p>
|
||||
<h2 class="mb-1 text-2xl font-bold">Dashboard</h2>
|
||||
<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}
|
||||
<div class="mb-8 flex items-center gap-4 rounded-xl border border-emerald-200 bg-emerald-50 p-5">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100">
|
||||
<Icon icon="ph:seal-check-duotone" class="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-emerald-900">{data.plan === 'ltd' ? 'Lifetime Deal' : data.plan} Plan</h3>
|
||||
<p class="mt-0.5 text-sm text-emerald-700">
|
||||
License: {data.licenseKey ?? 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-3 gap-5">
|
||||
{#each cards as card}
|
||||
<a
|
||||
href="/dashboard/devices"
|
||||
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">Devices</h3>
|
||||
<p class="mt-1 text-sm text-neutral-500">Manage connected phones</p>
|
||||
</a>
|
||||
<a
|
||||
href="/dashboard/api-keys"
|
||||
class="rounded-lg border border-neutral-200 p-6 hover:border-neutral-400"
|
||||
>
|
||||
<h3 class="font-semibold">API Keys</h3>
|
||||
<p class="mt-1 text-sm text-neutral-500">Create keys for your devices</p>
|
||||
</a>
|
||||
<a
|
||||
href="/dashboard/settings"
|
||||
class="rounded-lg border border-neutral-200 p-6 hover:border-neutral-400"
|
||||
>
|
||||
<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}">
|
||||
<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>
|
||||
|
||||
113
web/src/routes/dashboard/activate/+page.svelte
Normal file
113
web/src/routes/dashboard/activate/+page.svelte
Normal 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}
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 keysPromise = $state(listKeys());
|
||||
@@ -8,12 +10,14 @@
|
||||
if (createKey.result?.key) {
|
||||
newKeyValue = createKey.result.key;
|
||||
keysPromise = listKeys();
|
||||
toast.success('API key created');
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (deleteKey.result?.deleted) {
|
||||
keysPromise = listKeys();
|
||||
toast.success('API key deleted');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -21,15 +25,18 @@
|
||||
<h2 class="mb-6 text-2xl font-bold">API Keys</h2>
|
||||
|
||||
<!-- Create new key -->
|
||||
<div class="mb-8 rounded-lg border border-neutral-200 p-6">
|
||||
<h3 class="mb-4 font-semibold">Create New Key</h3>
|
||||
<div class="mb-8 rounded-xl border border-neutral-200 p-6">
|
||||
<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">
|
||||
<label class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-neutral-600">Key Name</span>
|
||||
<input
|
||||
{...createKey.fields.name.as('text')}
|
||||
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)}
|
||||
<p class="text-sm text-red-600">{issue.message}</p>
|
||||
@@ -37,8 +44,9 @@
|
||||
</label>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
@@ -46,21 +54,26 @@
|
||||
|
||||
<!-- Newly created key warning -->
|
||||
{#if newKeyValue}
|
||||
<div class="mb-8 rounded-lg 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-8 rounded-xl border border-yellow-300 bg-yellow-50 p-6">
|
||||
<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">
|
||||
Copy this key now. It will not be shown again.
|
||||
</p>
|
||||
<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}
|
||||
</code>
|
||||
<button
|
||||
onclick={() => {
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -74,21 +87,29 @@
|
||||
{/if}
|
||||
|
||||
<!-- Existing keys list -->
|
||||
<div class="rounded-lg border border-neutral-200">
|
||||
<div class="border-b border-neutral-200 px-6 py-4">
|
||||
<div class="rounded-xl border border-neutral-200">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
{#if keys && keys.length > 0}
|
||||
<ul class="divide-y divide-neutral-100">
|
||||
{#each keys as key (key.id)}
|
||||
<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>
|
||||
<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}
|
||||
<span class="font-mono">{key.start}...</span>
|
||||
{/if}
|
||||
@@ -97,12 +118,14 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form {...deleteKey}>
|
||||
<input type="hidden" name="keyId" value={key.id} />
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</form>
|
||||
@@ -110,12 +133,14 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="px-6 py-8 text-center text-sm text-neutral-500">
|
||||
No API keys yet. Create one above.
|
||||
<div class="px-6 py-10 text-center">
|
||||
<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>
|
||||
{/if}
|
||||
{: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.
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import DeviceCard from '$lib/components/DeviceCard.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
interface DeviceEntry {
|
||||
deviceId: string;
|
||||
@@ -67,12 +69,14 @@
|
||||
...devices
|
||||
];
|
||||
}
|
||||
toast.success(`${name} connected`);
|
||||
} else if (msg.type === 'device_offline') {
|
||||
const id = msg.deviceId as string;
|
||||
const existing = devices.find((d) => d.deviceId === id);
|
||||
if (existing) {
|
||||
existing.status = 'offline';
|
||||
devices = [...devices];
|
||||
toast.info(`${existing.name} disconnected`);
|
||||
}
|
||||
} else if (msg.type === 'device_status') {
|
||||
const id = msg.deviceId as string;
|
||||
@@ -91,12 +95,19 @@
|
||||
<h2 class="mb-6 text-2xl font-bold">Devices</h2>
|
||||
|
||||
{#if devices.length === 0}
|
||||
<div class="rounded-lg border border-neutral-200 p-8 text-center">
|
||||
<p class="text-neutral-500">No devices connected.</p>
|
||||
<p class="mt-2 text-sm text-neutral-400">
|
||||
<div class="rounded-xl border border-neutral-200 p-10 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: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.
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,64 @@
|
||||
<script lang="ts">
|
||||
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 layoutData = page.data;
|
||||
|
||||
$effect(() => {
|
||||
if (updateConfig.result?.saved) {
|
||||
toast.success('Settings saved');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2 class="mb-6 text-2xl font-bold">Settings</h2>
|
||||
|
||||
<div class="max-w-lg rounded-lg border border-neutral-200 p-6">
|
||||
<h3 class="mb-4 font-semibold">LLM Provider</h3>
|
||||
<div class="mb-6 max-w-lg rounded-xl border border-neutral-200 p-6">
|
||||
<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">
|
||||
<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
|
||||
{...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="groq">Groq</option>
|
||||
@@ -28,11 +72,14 @@
|
||||
</label>
|
||||
|
||||
<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
|
||||
{...updateConfig.fields.apiKey.as('password')}
|
||||
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)}
|
||||
<p class="text-sm text-red-600">{issue.message}</p>
|
||||
@@ -40,23 +87,31 @@
|
||||
</label>
|
||||
|
||||
<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
|
||||
{...updateConfig.fields.model.as('text')}
|
||||
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>
|
||||
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#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} · Key: {config.apiKey}
|
||||
{#if config.model} · Model: {config.model}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user