mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +01:00
Add API key management endpoints and integrate API keys section in settings panel
This commit is contained in:
@@ -56,6 +56,7 @@
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nikiv/ts-utils": "^0.1.7",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^24.10.1",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
ArrowLeft,
|
||||
SlidersHorizontal,
|
||||
@@ -6,9 +5,10 @@ import {
|
||||
type LucideIcon,
|
||||
CreditCard,
|
||||
Video,
|
||||
Key,
|
||||
} from "lucide-react"
|
||||
|
||||
type SettingsSection = "preferences" | "profile" | "streaming" | "billing"
|
||||
type SettingsSection = "preferences" | "profile" | "streaming" | "api" | "billing"
|
||||
|
||||
interface UserProfile {
|
||||
name?: string | null
|
||||
@@ -33,35 +33,10 @@ const navItems: NavItem[] = [
|
||||
{ id: "preferences", label: "Preferences", icon: SlidersHorizontal },
|
||||
{ id: "profile", label: "Profile", icon: UserRound },
|
||||
{ id: "streaming", label: "Streaming", icon: Video },
|
||||
{ id: "api", label: "API Keys", icon: Key },
|
||||
{ id: "billing", label: "Manage Billing", icon: CreditCard },
|
||||
]
|
||||
|
||||
function Avatar({ profile }: { profile?: UserProfile | null }) {
|
||||
const initial = useMemo(() => {
|
||||
if (!profile) return "G"
|
||||
return (
|
||||
profile.name?.slice(0, 1) ??
|
||||
profile.email?.slice(0, 1)?.toUpperCase() ??
|
||||
"G"
|
||||
)
|
||||
}, [profile])
|
||||
|
||||
if (profile?.image) {
|
||||
return (
|
||||
<img
|
||||
src={profile.image}
|
||||
alt={profile.name ?? profile.email}
|
||||
className="w-9 h-9 rounded-full object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-9 h-9 rounded-full bg-teal-600 text-white text-sm font-semibold grid place-items-center">
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPanel({
|
||||
activeSection,
|
||||
|
||||
@@ -45,6 +45,7 @@ import { Route as ApiChatMessagesRouteImport } from './routes/api/chat-messages'
|
||||
import { Route as ApiCanvasRouteImport } from './routes/api/canvas'
|
||||
import { Route as ApiBrowserSessionsRouteImport } from './routes/api/browser-sessions'
|
||||
import { Route as ApiArchivesRouteImport } from './routes/api/archives'
|
||||
import { Route as ApiApiKeysRouteImport } from './routes/api/api-keys'
|
||||
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs'
|
||||
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request'
|
||||
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names'
|
||||
@@ -260,6 +261,11 @@ const ApiArchivesRoute = ApiArchivesRouteImport.update({
|
||||
path: '/api/archives',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiApiKeysRoute = ApiApiKeysRouteImport.update({
|
||||
id: '/api/api-keys',
|
||||
path: '/api/api-keys',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({
|
||||
id: '/demo/start/server-funcs',
|
||||
path: '/demo/start/server-funcs',
|
||||
@@ -454,6 +460,7 @@ export interface FileRoutesByFullPath {
|
||||
'/streams': typeof StreamsRoute
|
||||
'/urls': typeof UrlsRoute
|
||||
'/users': typeof UsersRoute
|
||||
'/api/api-keys': typeof ApiApiKeysRoute
|
||||
'/api/archives': typeof ApiArchivesRouteWithChildren
|
||||
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
|
||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||
@@ -525,6 +532,7 @@ export interface FileRoutesByTo {
|
||||
'/streams': typeof StreamsRoute
|
||||
'/urls': typeof UrlsRoute
|
||||
'/users': typeof UsersRoute
|
||||
'/api/api-keys': typeof ApiApiKeysRoute
|
||||
'/api/archives': typeof ApiArchivesRouteWithChildren
|
||||
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
|
||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||
@@ -598,6 +606,7 @@ export interface FileRoutesById {
|
||||
'/streams': typeof StreamsRoute
|
||||
'/urls': typeof UrlsRoute
|
||||
'/users': typeof UsersRoute
|
||||
'/api/api-keys': typeof ApiApiKeysRoute
|
||||
'/api/archives': typeof ApiArchivesRouteWithChildren
|
||||
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
|
||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||
@@ -672,6 +681,7 @@ export interface FileRouteTypes {
|
||||
| '/streams'
|
||||
| '/urls'
|
||||
| '/users'
|
||||
| '/api/api-keys'
|
||||
| '/api/archives'
|
||||
| '/api/browser-sessions'
|
||||
| '/api/canvas'
|
||||
@@ -743,6 +753,7 @@ export interface FileRouteTypes {
|
||||
| '/streams'
|
||||
| '/urls'
|
||||
| '/users'
|
||||
| '/api/api-keys'
|
||||
| '/api/archives'
|
||||
| '/api/browser-sessions'
|
||||
| '/api/canvas'
|
||||
@@ -815,6 +826,7 @@ export interface FileRouteTypes {
|
||||
| '/streams'
|
||||
| '/urls'
|
||||
| '/users'
|
||||
| '/api/api-keys'
|
||||
| '/api/archives'
|
||||
| '/api/browser-sessions'
|
||||
| '/api/canvas'
|
||||
@@ -888,6 +900,7 @@ export interface RootRouteChildren {
|
||||
StreamsRoute: typeof StreamsRoute
|
||||
UrlsRoute: typeof UrlsRoute
|
||||
UsersRoute: typeof UsersRoute
|
||||
ApiApiKeysRoute: typeof ApiApiKeysRoute
|
||||
ApiArchivesRoute: typeof ApiArchivesRouteWithChildren
|
||||
ApiBrowserSessionsRoute: typeof ApiBrowserSessionsRouteWithChildren
|
||||
ApiCanvasRoute: typeof ApiCanvasRouteWithChildren
|
||||
@@ -1183,6 +1196,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiArchivesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/api-keys': {
|
||||
id: '/api/api-keys'
|
||||
path: '/api/api-keys'
|
||||
fullPath: '/api/api-keys'
|
||||
preLoaderRoute: typeof ApiApiKeysRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/start/server-funcs': {
|
||||
id: '/demo/start/server-funcs'
|
||||
path: '/demo/start/server-funcs'
|
||||
@@ -1589,6 +1609,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
StreamsRoute: StreamsRoute,
|
||||
UrlsRoute: UrlsRoute,
|
||||
UsersRoute: UsersRoute,
|
||||
ApiApiKeysRoute: ApiApiKeysRoute,
|
||||
ApiArchivesRoute: ApiArchivesRouteWithChildren,
|
||||
ApiBrowserSessionsRoute: ApiBrowserSessionsRouteWithChildren,
|
||||
ApiCanvasRoute: ApiCanvasRouteWithChildren,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { createAPIFileRoute } from "@tanstack/react-start/api"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { api_keys } from "@/db/schema"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { headers } from "@tanstack/react-start/server"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
|
||||
const json = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
// Generate a random API key
|
||||
function generateApiKey(): string {
|
||||
@@ -24,110 +29,117 @@ async function hashApiKey(key: string): Promise<string> {
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
export const APIRoute = createAPIFileRoute("/api/api-keys")({
|
||||
// GET - List user's API keys (without the actual key, just metadata)
|
||||
GET: async () => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
export const Route = createFileRoute("/api/api-keys")({
|
||||
server: {
|
||||
handlers: {
|
||||
// GET - List user's API keys (without the actual key, just metadata)
|
||||
GET: async ({ request }) => {
|
||||
try {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
const keys = await db
|
||||
.select({
|
||||
id: api_keys.id,
|
||||
name: api_keys.name,
|
||||
last_used_at: api_keys.last_used_at,
|
||||
created_at: api_keys.created_at,
|
||||
})
|
||||
.from(api_keys)
|
||||
.where(eq(api_keys.user_id, session.user.id))
|
||||
.orderBy(api_keys.created_at)
|
||||
const keys = await db
|
||||
.select({
|
||||
id: api_keys.id,
|
||||
name: api_keys.name,
|
||||
last_used_at: api_keys.last_used_at,
|
||||
created_at: api_keys.created_at,
|
||||
})
|
||||
.from(api_keys)
|
||||
.where(eq(api_keys.user_id, session.user.id))
|
||||
.orderBy(api_keys.created_at)
|
||||
|
||||
return Response.json({ keys })
|
||||
} catch (error) {
|
||||
console.error("Error fetching API keys:", error)
|
||||
return Response.json({ error: "Failed to fetch API keys" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
return json({ keys })
|
||||
} catch (error) {
|
||||
console.error("Error fetching API keys:", error)
|
||||
return json({ error: "Failed to fetch API keys" }, 500)
|
||||
}
|
||||
},
|
||||
|
||||
// POST - Create a new API key
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
// POST - Create a new API key
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const name = body.name || "Default"
|
||||
const body = (await request.json().catch(() => ({}))) as { name?: string }
|
||||
const name = body.name || "Default"
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
// Generate new key
|
||||
const plainKey = generateApiKey()
|
||||
const keyHash = await hashApiKey(plainKey)
|
||||
// Generate new key
|
||||
const plainKey = generateApiKey()
|
||||
const keyHash = await hashApiKey(plainKey)
|
||||
|
||||
// Insert key record
|
||||
const [keyRecord] = await db
|
||||
.insert(api_keys)
|
||||
.values({
|
||||
user_id: session.user.id,
|
||||
key_hash: keyHash,
|
||||
name,
|
||||
})
|
||||
.returning({
|
||||
id: api_keys.id,
|
||||
name: api_keys.name,
|
||||
created_at: api_keys.created_at,
|
||||
})
|
||||
// Insert key record
|
||||
const [keyRecord] = await db
|
||||
.insert(api_keys)
|
||||
.values({
|
||||
user_id: session.user.id,
|
||||
key_hash: keyHash,
|
||||
name,
|
||||
})
|
||||
.returning({
|
||||
id: api_keys.id,
|
||||
name: api_keys.name,
|
||||
created_at: api_keys.created_at,
|
||||
})
|
||||
|
||||
// Return the plain key ONLY on creation (it won't be retrievable later)
|
||||
return Response.json({
|
||||
key: plainKey,
|
||||
id: keyRecord.id,
|
||||
name: keyRecord.name,
|
||||
created_at: keyRecord.created_at,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error creating API key:", error)
|
||||
return Response.json({ error: "Failed to create API key" }, { status: 500 })
|
||||
}
|
||||
},
|
||||
// Return the plain key ONLY on creation (it won't be retrievable later)
|
||||
return json({
|
||||
key: plainKey,
|
||||
id: keyRecord.id,
|
||||
name: keyRecord.name,
|
||||
created_at: keyRecord.created_at,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error creating API key:", error)
|
||||
return json({ error: "Failed to create API key" }, 500)
|
||||
}
|
||||
},
|
||||
|
||||
// DELETE - Revoke an API key
|
||||
DELETE: async ({ request }) => {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user?.id) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
// DELETE - Revoke an API key
|
||||
DELETE: async ({ request }) => {
|
||||
try {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const keyId = url.searchParams.get("id")
|
||||
const url = new URL(request.url)
|
||||
const keyId = url.searchParams.get("id")
|
||||
|
||||
if (!keyId) {
|
||||
return Response.json({ error: "Key ID is required" }, { status: 400 })
|
||||
}
|
||||
if (!keyId) {
|
||||
return json({ error: "Key ID is required" }, 400)
|
||||
}
|
||||
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
const db = getDb(process.env.DATABASE_URL!)
|
||||
|
||||
// Delete key (only if it belongs to the user)
|
||||
const [deleted] = await db
|
||||
.delete(api_keys)
|
||||
.where(eq(api_keys.id, keyId))
|
||||
.returning()
|
||||
// Delete key (only if it belongs to the user)
|
||||
const [deleted] = await db
|
||||
.delete(api_keys)
|
||||
.where(eq(api_keys.id, keyId))
|
||||
.returning()
|
||||
|
||||
if (!deleted) {
|
||||
return Response.json({ error: "Key not found" }, { status: 404 })
|
||||
}
|
||||
if (!deleted) {
|
||||
return json({ error: "Key not found" }, 404)
|
||||
}
|
||||
|
||||
return Response.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Error deleting API key:", error)
|
||||
return Response.json({ error: "Failed to delete API key" }, { status: 500 })
|
||||
}
|
||||
return json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Error deleting API key:", error)
|
||||
return json({ error: "Failed to delete API key" }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,9 +12,14 @@ import {
|
||||
HelpCircle,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Key,
|
||||
Trash2,
|
||||
Plus,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react"
|
||||
|
||||
type SectionId = "preferences" | "profile" | "streaming" | "billing"
|
||||
type SectionId = "preferences" | "profile" | "streaming" | "api" | "billing"
|
||||
|
||||
const PLAN_CARD_NOISE =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E"
|
||||
@@ -842,6 +847,232 @@ function StreamingSection({ username }: { username: string | null | undefined })
|
||||
)
|
||||
}
|
||||
|
||||
interface ApiKeyData {
|
||||
id: string
|
||||
name: string
|
||||
last_used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function ApiKeysSection() {
|
||||
const [keys, setKeys] = useState<ApiKeyData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState("")
|
||||
const [newKey, setNewKey] = useState<string | null>(null)
|
||||
const [showNewKey, setShowNewKey] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/api-keys", { credentials: "include" })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setKeys(data.keys || [])
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys()
|
||||
}, [])
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch("/api/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name: newKeyName || "Default" }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to create key")
|
||||
} else {
|
||||
setNewKey(data.key)
|
||||
setShowNewKey(true)
|
||||
setNewKeyName("")
|
||||
fetchKeys()
|
||||
}
|
||||
} catch {
|
||||
setError("Network error")
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteKey = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/api-keys?id=${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) {
|
||||
setKeys(keys.filter((k) => k.id !== id))
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
const copyKey = () => {
|
||||
if (newKey) {
|
||||
navigator.clipboard.writeText(newKey)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return "Never"
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="api" className="scroll-mt-24">
|
||||
<SectionHeader
|
||||
title="API Keys"
|
||||
description="Manage your API keys for programmatic access."
|
||||
/>
|
||||
<div className="space-y-5">
|
||||
{/* Create new key */}
|
||||
<SettingCard title="Create API Key">
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="p-3 bg-teal-500/10 border border-teal-500/20 rounded-lg">
|
||||
<p className="text-sm text-teal-300">
|
||||
API keys allow you to access Linsa programmatically. Use them to save bookmarks, sync data, and integrate with other tools.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Key name (optional)"
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateKey}
|
||||
disabled={creating}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{creating ? "Creating..." : "Create Key"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-rose-400">{error}</p>}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
{/* New key display */}
|
||||
{newKey && (
|
||||
<SettingCard title="New API Key">
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<p className="text-sm text-yellow-300">
|
||||
Copy this key now. You won't be able to see it again!
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-teal-400 text-sm font-mono overflow-x-auto">
|
||||
{showNewKey ? newKey : "•".repeat(40)}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewKey(!showNewKey)}
|
||||
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
{showNewKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyKey}
|
||||
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-teal-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewKey(null)}
|
||||
className="text-sm text-white/50 hover:text-white"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</SettingCard>
|
||||
)}
|
||||
|
||||
{/* Existing keys */}
|
||||
<SettingCard title="Your API Keys">
|
||||
<div className="py-2">
|
||||
{loading ? (
|
||||
<div className="h-20 bg-white/5 rounded-lg animate-pulse" />
|
||||
) : keys.length === 0 ? (
|
||||
<p className="text-sm text-white/50 py-4 text-center">
|
||||
No API keys yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{keys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-3 bg-white/5 rounded-lg border border-white/10"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="w-4 h-4 text-white/50" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{key.name}</p>
|
||||
<p className="text-xs text-white/50">
|
||||
Created {formatDate(key.created_at)} • Last used {formatDate(key.last_used_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteKey(key.id)}
|
||||
className="p-2 text-rose-400 hover:bg-rose-400/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
{/* Usage example */}
|
||||
<SettingCard title="Usage">
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-sm text-white/70">
|
||||
Use your API key to save bookmarks:
|
||||
</p>
|
||||
<pre className="bg-white/5 border border-white/10 rounded-lg p-3 text-sm text-white/80 overflow-x-auto">
|
||||
{`curl -X POST https://linsa.io/api/bookmarks \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"url": "https://example.com",
|
||||
"title": "Example",
|
||||
"api_key": "lk_your_key_here"
|
||||
}'`}
|
||||
</pre>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingSection() {
|
||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -1031,6 +1262,8 @@ function SettingsPage() {
|
||||
/>
|
||||
) : activeSection === "streaming" ? (
|
||||
<StreamingSection username={session?.user?.username} />
|
||||
) : activeSection === "api" ? (
|
||||
<ApiKeysSection />
|
||||
) : activeSection === "billing" && BILLING_ENABLED ? (
|
||||
<BillingSection />
|
||||
) : null}
|
||||
|
||||
@@ -1,127 +1,57 @@
|
||||
/**
|
||||
* Test script to save Safari tabs as bookmarks to Linsa
|
||||
* Save Safari tabs as bookmarks to Linsa
|
||||
*
|
||||
* Usage:
|
||||
* pnpm tsx tests/bookmarks-save.ts
|
||||
* LINSA_API_KEY=lk_xxx pnpm tsx tests/bookmarks-save.ts
|
||||
*
|
||||
* Requires:
|
||||
* - LINSA_API_KEY environment variable (or create one at /settings)
|
||||
* - Safari running with tabs open
|
||||
* Or via flow:
|
||||
* f save-tabs
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process"
|
||||
import { executeJxa } from "@nikiv/ts-utils"
|
||||
|
||||
const API_URL = process.env.LINSA_API_URL || "http://localhost:5613"
|
||||
const API_KEY = process.env.LINSA_API_KEY
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("Error: LINSA_API_KEY environment variable is required")
|
||||
console.error("Generate one at /settings or via POST /api/api-keys")
|
||||
console.error("Generate one at /settings or via: f gen-api-key")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
interface SafariTab {
|
||||
type LocalTab = {
|
||||
uuid: string
|
||||
title: string
|
||||
url: string
|
||||
windowIndex: number
|
||||
window_id: number
|
||||
index: number
|
||||
is_local: boolean
|
||||
}
|
||||
|
||||
// Get Safari tabs using AppleScript
|
||||
function getSafariTabs(): SafariTab[] {
|
||||
const script = `
|
||||
tell application "Safari"
|
||||
set tabList to {}
|
||||
set windowCount to count of windows
|
||||
repeat with w from 1 to windowCount
|
||||
set tabCount to count of tabs of window w
|
||||
repeat with t from 1 to tabCount
|
||||
set tabTitle to name of tab t of window w
|
||||
set tabURL to URL of tab t of window w
|
||||
set end of tabList to {windowIndex:w, title:tabTitle, url:tabURL}
|
||||
end repeat
|
||||
end repeat
|
||||
return tabList
|
||||
end tell
|
||||
`
|
||||
|
||||
try {
|
||||
const result = execSync(`osascript -e '${script.replace(/'/g, "'\\''")}'`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Parse AppleScript output: {{windowIndex:1, title:"...", url:"..."}, ...}
|
||||
const tabs: SafariTab[] = []
|
||||
|
||||
// AppleScript returns records in format: window index:1, title:..., url:...
|
||||
const matches = result.matchAll(
|
||||
/window ?[iI]ndex:(\d+),\s*title:(.*?),\s*url:(.*?)(?=,\s*window|$)/g
|
||||
)
|
||||
|
||||
for (const match of matches) {
|
||||
tabs.push({
|
||||
windowIndex: parseInt(match[1]),
|
||||
title: match[2].trim(),
|
||||
url: match[3].trim(),
|
||||
})
|
||||
}
|
||||
|
||||
// If regex didn't work, try simpler line-by-line parsing
|
||||
if (tabs.length === 0) {
|
||||
// Alternative: get just URLs and titles separately
|
||||
const urlScript = `
|
||||
tell application "Safari"
|
||||
set urls to {}
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
set end of urls to URL of t
|
||||
end repeat
|
||||
end repeat
|
||||
return urls
|
||||
end tell
|
||||
`
|
||||
const titleScript = `
|
||||
tell application "Safari"
|
||||
set titles to {}
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
set end of titles to name of t
|
||||
end repeat
|
||||
end repeat
|
||||
return titles
|
||||
end tell
|
||||
`
|
||||
|
||||
const urlsRaw = execSync(`osascript -e '${urlScript.replace(/'/g, "'\\''")}'`, {
|
||||
encoding: "utf-8",
|
||||
}).trim()
|
||||
|
||||
const titlesRaw = execSync(`osascript -e '${titleScript.replace(/'/g, "'\\''")}'`, {
|
||||
encoding: "utf-8",
|
||||
}).trim()
|
||||
|
||||
// Parse comma-separated lists
|
||||
const urls = urlsRaw.split(", ").filter(Boolean)
|
||||
const titles = titlesRaw.split(", ").filter(Boolean)
|
||||
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
tabs.push({
|
||||
windowIndex: 1,
|
||||
title: titles[i] || "",
|
||||
url: urls[i],
|
||||
async function fetchSafariTabs(): Promise<LocalTab[]> {
|
||||
return executeJxa(`
|
||||
const safari = Application("com.apple.Safari");
|
||||
const tabs = [];
|
||||
safari.windows().map(window => {
|
||||
const windowTabs = window.tabs();
|
||||
if (windowTabs) {
|
||||
return windowTabs.map(tab => {
|
||||
tabs.push({
|
||||
uuid: window.id() + '-' + tab.index(),
|
||||
title: tab.name(),
|
||||
url: tab.url() || '',
|
||||
window_id: window.id(),
|
||||
index: tab.index(),
|
||||
is_local: true
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tabs
|
||||
} catch (error) {
|
||||
console.error("Failed to get Safari tabs:", error)
|
||||
return []
|
||||
}
|
||||
});
|
||||
return tabs;
|
||||
`)
|
||||
}
|
||||
|
||||
// Save a bookmark to Linsa
|
||||
async function saveBookmark(tab: SafariTab, sessionTag: string): Promise<boolean> {
|
||||
async function saveBookmark(tab: LocalTab, sessionTag: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/bookmarks`, {
|
||||
method: "POST",
|
||||
@@ -138,59 +68,63 @@ async function saveBookmark(tab: SafariTab, sessionTag: string): Promise<boolean
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
console.error(`Failed to save ${tab.url}:`, error)
|
||||
console.error(` ✗ Failed: ${error.error || response.status}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`Failed to save ${tab.url}:`, error)
|
||||
console.error(` ✗ Error:`, error instanceof Error ? error.message : error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Getting Safari tabs...")
|
||||
const tabs = getSafariTabs()
|
||||
const tabs = await fetchSafariTabs()
|
||||
|
||||
if (tabs.length === 0) {
|
||||
console.log("No Safari tabs found. Make sure Safari is running with tabs open.")
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Found ${tabs.length} tabs`)
|
||||
console.log(`Found ${tabs.length} tabs across ${new Set(tabs.map((t) => t.window_id)).size} windows`)
|
||||
|
||||
// Create session tag with timestamp
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
|
||||
const date = new Date()
|
||||
const timestamp = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}-${String(date.getHours()).padStart(2, "0")}${String(date.getMinutes()).padStart(2, "0")}`
|
||||
const sessionTag = `session-${timestamp}`
|
||||
|
||||
console.log(`Saving to Linsa with tag: ${sessionTag}`)
|
||||
console.log(`\nSaving to Linsa with tag: ${sessionTag}`)
|
||||
console.log(`API URL: ${API_URL}`)
|
||||
console.log("")
|
||||
|
||||
let saved = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
|
||||
for (const tab of tabs) {
|
||||
// Skip empty URLs or about: pages
|
||||
// Skip empty URLs, about: pages, favorites
|
||||
if (!tab.url || tab.url.startsWith("about:") || tab.url === "favorites://") {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
process.stdout.write(` Saving: ${tab.title.slice(0, 50)}... `)
|
||||
const shortTitle = tab.title.length > 50 ? tab.title.slice(0, 47) + "..." : tab.title
|
||||
process.stdout.write(` ${shortTitle} `)
|
||||
|
||||
const success = await saveBookmark(tab, sessionTag)
|
||||
|
||||
if (success) {
|
||||
console.log("✓")
|
||||
saved++
|
||||
} else {
|
||||
console.log("✗")
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
console.log("")
|
||||
console.log(`Done! Saved ${saved} bookmarks, ${failed} failed`)
|
||||
console.log(`Done! Saved: ${saved}, Skipped: ${skipped}, Failed: ${failed}`)
|
||||
console.log(`Session tag: ${sessionTag}`)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user