feat: add better-auth api-key plugin, import endpoint, and API key UI
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"name": "deckyvault",
|
"name": "deckyvault",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1036.0",
|
"@aws-sdk/client-s3": "^3.1036.0",
|
||||||
|
"@better-auth/api-key": "^1.6.22",
|
||||||
"@better-auth/drizzle-adapter": "^1.6.9",
|
"@better-auth/drizzle-adapter": "^1.6.9",
|
||||||
"@better-auth/expo": "^1.6.11",
|
"@better-auth/expo": "^1.6.11",
|
||||||
"@better-auth/passkey": "^1.6.9",
|
"@better-auth/passkey": "^1.6.9",
|
||||||
@@ -189,6 +190,8 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
|
"@better-auth/api-key": ["@better-auth/api-key@1.6.22", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.22", "@better-auth/utils": "0.4.2", "better-auth": "^1.6.22", "better-call": "1.3.7" } }, "sha512-HDiiLYF0ov0zqhKv4CMTyLwpjTZ3UWl2dug451uTw40VM9zGxWSNxwILDcMDZ6hS5evaTHmmk7R5gciskEk2nQ=="],
|
||||||
|
|
||||||
"@better-auth/core": ["@better-auth/core@1.6.9", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w=="],
|
"@better-auth/core": ["@better-auth/core@1.6.9", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w=="],
|
||||||
|
|
||||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="],
|
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="],
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { authClient } from "@/lib/auth-client"
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Key,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { motion, AnimatePresence } from "motion/react"
|
||||||
|
|
||||||
|
// Raw API key type from Better Auth (dates are Date objects from the API)
|
||||||
|
interface RawApiKey {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
start: string | null
|
||||||
|
prefix: string | null
|
||||||
|
enabled: boolean
|
||||||
|
expiresAt: Date | null
|
||||||
|
lastRequest: Date | null
|
||||||
|
remaining: number | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
referenceId: string
|
||||||
|
metadata: Record<string, unknown> | null
|
||||||
|
permissions: Record<string, string[]> | null
|
||||||
|
configId: string
|
||||||
|
refillInterval: number | null
|
||||||
|
refillAmount: number | null
|
||||||
|
lastRefillAt: Date | null
|
||||||
|
rateLimitEnabled: boolean
|
||||||
|
rateLimitTimeWindow: number | null
|
||||||
|
rateLimitMax: number | null
|
||||||
|
requestCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedApiKey extends RawApiKey {
|
||||||
|
key: string // Only returned on creation
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsApiKeysTab() {
|
||||||
|
const [apiKeys, setApiKeys] = useState<RawApiKey[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [newKeyName, setNewKeyName] = useState("")
|
||||||
|
const [newKeyExpiresIn, setNewKeyExpiresIn] = useState("")
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null)
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Delete state
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
const [message, setMessage] = useState<{
|
||||||
|
type: "success" | "error"
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// Copy state
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [showKey, setShowKey] = useState(false)
|
||||||
|
|
||||||
|
const fetchApiKeys = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await authClient.apiKey.list({})
|
||||||
|
if (error) {
|
||||||
|
setError(error.message || "Failed to load API keys")
|
||||||
|
setApiKeys([])
|
||||||
|
} else {
|
||||||
|
setApiKeys((data?.apiKeys ?? []) as RawApiKey[])
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load API keys. Please try again.")
|
||||||
|
setApiKeys([])
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApiKeys()
|
||||||
|
}, [fetchApiKeys])
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCreateError(null)
|
||||||
|
|
||||||
|
if (!newKeyName.trim()) {
|
||||||
|
setCreateError("Name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true)
|
||||||
|
try {
|
||||||
|
const expiresIn = newKeyExpiresIn
|
||||||
|
? parseInt(newKeyExpiresIn) * 24 * 60 * 60
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const { data, error } = await authClient.apiKey.create({
|
||||||
|
name: newKeyName.trim(),
|
||||||
|
expiresIn,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setCreateError(error.message || "Failed to create API key")
|
||||||
|
} else if (data) {
|
||||||
|
setCreatedKey(data as unknown as CreatedApiKey)
|
||||||
|
await fetchApiKeys()
|
||||||
|
setNewKeyName("")
|
||||||
|
setNewKeyExpiresIn("")
|
||||||
|
setShowCreateForm(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCreateError("An unexpected error occurred")
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (keyId: string) => {
|
||||||
|
setDeletingId(keyId)
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
const { error } = await authClient.apiKey.delete({ keyId })
|
||||||
|
if (error) {
|
||||||
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: error.message || "Failed to delete API key",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "success", text: "API key deleted" })
|
||||||
|
await fetchApiKeys()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to delete API key. Please try again.",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date | string | null): string => {
|
||||||
|
if (!date) return "Never"
|
||||||
|
try {
|
||||||
|
const d = date instanceof Date ? date : new Date(date)
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return String(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyKey = async () => {
|
||||||
|
if (createdKey?.key) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(createdKey.key)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.value = createdKey.key
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand("copy")
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Show the created key modal
|
||||||
|
if (createdKey) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className='space-y-6'
|
||||||
|
>
|
||||||
|
<div className='rounded-xl border border-green-500/30 bg-green-500/10 p-6'>
|
||||||
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
|
<Check className='h-5 w-5 text-green-400' />
|
||||||
|
<h3 className='text-sm font-medium text-green-400'>
|
||||||
|
API Key Created
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='text-sm text-text/70 mb-3'>
|
||||||
|
Copy your API key now. You won't be able to see it again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='relative mb-4'>
|
||||||
|
<div className='w-full px-4 py-3 rounded-lg bg-text/5 border border-border font-mono text-sm break-all pr-20'>
|
||||||
|
{showKey
|
||||||
|
? createdKey.key
|
||||||
|
: `${createdKey.key.substring(0, 12)}${"•".repeat(Math.min(createdKey.key.length - 12, 20))}`}
|
||||||
|
</div>
|
||||||
|
<div className='absolute right-2 top-1/2 -translate-y-1/2 flex gap-1'>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKey(!showKey)}
|
||||||
|
className='p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer'
|
||||||
|
title={showKey ? "Hide key" : "Show key"}
|
||||||
|
>
|
||||||
|
{showKey ? (
|
||||||
|
<EyeOff className='h-4 w-4' />
|
||||||
|
) : (
|
||||||
|
<Eye className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyKey}
|
||||||
|
className='p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer'
|
||||||
|
title='Copy to clipboard'
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className='h-4 w-4 text-green-400' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createdKey.name && (
|
||||||
|
<p className='text-xs text-text/50'>
|
||||||
|
Name: <span className='text-text/70'>{createdKey.name}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{createdKey.expiresAt && (
|
||||||
|
<p className='text-xs text-text/50 mt-1'>
|
||||||
|
Expires:{" "}
|
||||||
|
<span className='text-text/70'>
|
||||||
|
{formatDate(createdKey.expiresAt)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCreatedKey(null)}
|
||||||
|
className='mt-4 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className='space-y-6'
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h3 className='text-sm font-medium uppercase tracking-wider text-text/60 flex items-center gap-2'>
|
||||||
|
<Key className='h-4 w-4' />
|
||||||
|
API Keys
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateForm(!showCreateForm)
|
||||||
|
setCreateError(null)
|
||||||
|
}}
|
||||||
|
className='flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs font-medium hover:border-primary/40 hover:text-primary transition-colors cursor-pointer'
|
||||||
|
>
|
||||||
|
<Plus className='h-3.5 w-3.5' />
|
||||||
|
{showCreateForm ? "Cancel" : "Create Key"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`text-sm px-4 py-2 rounded-lg ${
|
||||||
|
message.type === "success"
|
||||||
|
? "bg-green-500/10 text-green-400 border border-green-500/20"
|
||||||
|
: "bg-red-500/10 text-red-400 border border-red-500/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Form */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showCreateForm && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className='rounded-xl border border-border bg-text/3 p-5 overflow-hidden'
|
||||||
|
>
|
||||||
|
<form onSubmit={handleCreate} className='space-y-3'>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs text-text/50 mb-1'>
|
||||||
|
Key Name <span className='text-red-400'>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
className='w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors'
|
||||||
|
placeholder='e.g. Decky Loader Plugin'
|
||||||
|
maxLength={32}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-xs text-text/50 mb-1'>
|
||||||
|
Expires In (days){" "}
|
||||||
|
<span className='text-text/40'>(optional — leave empty for no expiry)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
value={newKeyExpiresIn}
|
||||||
|
onChange={(e) => setNewKeyExpiresIn(e.target.value)}
|
||||||
|
className='w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors'
|
||||||
|
placeholder='Leave empty for no expiry'
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<p className='text-sm text-red-400 flex items-center gap-1'>
|
||||||
|
<AlertCircle className='h-3.5 w-3.5' />
|
||||||
|
{createError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={isCreating}
|
||||||
|
className='flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer w-full sm:w-auto'
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Key className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
Create API Key
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Key List */}
|
||||||
|
{error ? (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<p className='text-sm text-red-400 mb-2'>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setError(null)
|
||||||
|
setIsLoading(true)
|
||||||
|
fetchApiKeys()
|
||||||
|
}}
|
||||||
|
className='text-sm text-primary hover:underline cursor-pointer'
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className='flex items-center gap-2 text-sm text-text/50 py-8'>
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
Loading API keys...
|
||||||
|
</div>
|
||||||
|
) : apiKeys.length === 0 ? (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<Key className='h-8 w-8 text-text/20 mx-auto mb-3' />
|
||||||
|
<p className='text-sm text-text/50'>
|
||||||
|
No API keys created yet.
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-text/30 mt-1'>
|
||||||
|
Create an API key to use with the Decky Loader plugin or other external tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<AnimatePresence>
|
||||||
|
{apiKeys.map((ak) => (
|
||||||
|
<motion.div
|
||||||
|
key={ak.id}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className='flex items-center justify-between gap-3 rounded-lg border border-border bg-text/2 p-4'
|
||||||
|
>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Key className='h-4 w-4 text-text/40 shrink-0' />
|
||||||
|
<p className='text-sm font-medium text-text truncate'>
|
||||||
|
{ak.name || "Unnamed key"}
|
||||||
|
</p>
|
||||||
|
{!ak.enabled && (
|
||||||
|
<span className='text-xs px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'>
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 mt-1'>
|
||||||
|
{ak.start && (
|
||||||
|
<span className='text-xs font-mono text-text/40'>
|
||||||
|
{ak.start}...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ak.expiresAt && (
|
||||||
|
<span className='text-xs text-text/40 flex items-center gap-1'>
|
||||||
|
<Clock className='h-3 w-3' />
|
||||||
|
Expires {formatDate(ak.expiresAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ak.lastRequest && (
|
||||||
|
<span className='text-xs text-text/40'>
|
||||||
|
Last used {formatDate(ak.lastRequest)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className='text-xs text-text/30'>
|
||||||
|
Created {formatDate(ak.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(ak.id)}
|
||||||
|
disabled={deletingId === ak.id}
|
||||||
|
className='p-2 rounded hover:bg-red-500/10 text-text/40 hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0 cursor-pointer'
|
||||||
|
title='Delete API key'
|
||||||
|
>
|
||||||
|
{deletingId === ak.id ? (
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className='rounded-xl border border-border bg-text/2 p-4'>
|
||||||
|
<p className='text-xs text-text/40'>
|
||||||
|
<strong className='text-text/60'>Using API keys:</strong> Pass your API key as the{" "}
|
||||||
|
<code className='text-primary bg-text/5 px-1 rounded'>x-api-key</code> header when
|
||||||
|
making requests to DeckyVault's API. You can use these keys with the Decky
|
||||||
|
Loader plugin or any automation tool.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { User, Shield, Link as LinkIcon } from "lucide-react"
|
import { User, Shield, Link as LinkIcon, Key } from "lucide-react"
|
||||||
import { SettingsProfileTab } from "@/components/profile/settings-profile-tab"
|
import { SettingsProfileTab } from "@/components/profile/settings-profile-tab"
|
||||||
import { SettingsSecurityTab } from "@/components/profile/settings-security-tab"
|
import { SettingsSecurityTab } from "@/components/profile/settings-security-tab"
|
||||||
import { SettingsAccountsTab } from "@/components/profile/settings-accounts-tab"
|
import { SettingsAccountsTab } from "@/components/profile/settings-accounts-tab"
|
||||||
|
import { SettingsApiKeysTab } from "@/components/profile/settings-api-keys-tab"
|
||||||
|
|
||||||
interface AuthMethods {
|
interface AuthMethods {
|
||||||
hasPassword: boolean
|
hasPassword: boolean
|
||||||
@@ -13,12 +14,13 @@ interface AuthMethods {
|
|||||||
totalAuthMethods: number
|
totalAuthMethods: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsSubTab = "profile" | "security" | "accounts"
|
type SettingsSubTab = "profile" | "security" | "accounts" | "api-keys"
|
||||||
|
|
||||||
const subTabs: { id: SettingsSubTab; label: string; icon: typeof User }[] = [
|
const subTabs: { id: SettingsSubTab; label: string; icon: typeof User }[] = [
|
||||||
{ id: "profile", label: "Profile", icon: User },
|
{ id: "profile", label: "Profile", icon: User },
|
||||||
{ id: "security", label: "Security", icon: Shield },
|
{ id: "security", label: "Security", icon: Shield },
|
||||||
{ id: "accounts", label: "Linked Accounts", icon: LinkIcon },
|
{ id: "accounts", label: "Linked Accounts", icon: LinkIcon },
|
||||||
|
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SettingsContainerProps {
|
interface SettingsContainerProps {
|
||||||
@@ -145,6 +147,9 @@ export function SettingsContainer({
|
|||||||
onRefreshAuthMethods={refreshAuthMethods}
|
onRefreshAuthMethods={refreshAuthMethods}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeSubTab === "api-keys" && (
|
||||||
|
<SettingsApiKeysTab />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE "apikey" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"config_id" text DEFAULT 'default' NOT NULL,
|
||||||
|
"name" text,
|
||||||
|
"start" text,
|
||||||
|
"reference_id" text NOT NULL,
|
||||||
|
"prefix" text,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"refill_interval" integer,
|
||||||
|
"refill_amount" integer,
|
||||||
|
"last_refill_at" timestamp,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"rate_limit_enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"rate_limit_time_window" integer,
|
||||||
|
"rate_limit_max" integer,
|
||||||
|
"request_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"remaining" integer,
|
||||||
|
"last_request" timestamp,
|
||||||
|
"expires_at" timestamp,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"permissions" text,
|
||||||
|
"metadata" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "apikey_config_id_idx" ON "apikey" USING btree ("config_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "apikey_reference_id_idx" ON "apikey" USING btree ("reference_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -190,6 +190,13 @@
|
|||||||
"when": 1779214557250,
|
"when": 1779214557250,
|
||||||
"tag": "0026_polite_drax",
|
"tag": "0026_polite_drax",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782593630361,
|
||||||
|
"tag": "0027_slimy_stryfe",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,8 @@ import { db } from "@/lib/db"
|
|||||||
import { user } from "@/lib/db/schema/auth"
|
import { user } from "@/lib/db/schema/auth"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { mobileRoutes } from "@/lib/api/mobile"
|
import { mobileRoutes } from "@/lib/api/mobile"
|
||||||
|
import { gamesLookupRoutes } from "@/lib/api/games-lookup"
|
||||||
|
import { performanceImportRoutes } from "@/lib/api/performance-import"
|
||||||
|
|
||||||
const betterAuth = new Elysia({ name: "better-auth" })
|
const betterAuth = new Elysia({ name: "better-auth" })
|
||||||
.mount(auth.handler)
|
.mount(auth.handler)
|
||||||
@@ -241,6 +243,7 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
.use(standaloneVersionTestRoutes)
|
.use(standaloneVersionTestRoutes)
|
||||||
.use(gamesManualRoutes)
|
.use(gamesManualRoutes)
|
||||||
.use(screenshotRoutes)
|
.use(screenshotRoutes)
|
||||||
|
.use(gamesLookupRoutes)
|
||||||
.use(mobileRoutes)
|
.use(mobileRoutes)
|
||||||
)
|
)
|
||||||
// ── Write routes ───────────────────────────────────────────
|
// ── Write routes ───────────────────────────────────────────
|
||||||
@@ -256,6 +259,7 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
.use(adminPerformanceRoutes)
|
.use(adminPerformanceRoutes)
|
||||||
.use(adminCommentRoutes)
|
.use(adminCommentRoutes)
|
||||||
.use(adminStorageRoutes)
|
.use(adminStorageRoutes)
|
||||||
|
.use(performanceImportRoutes)
|
||||||
.use(adminAnalyticsRoutes)
|
.use(adminAnalyticsRoutes)
|
||||||
)
|
)
|
||||||
// ── Public forms (no auth) ─────────────────────────────────
|
// ── Public forms (no auth) ─────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Elysia, t } from "elysia"
|
||||||
|
import { db } from "@/lib/db/index"
|
||||||
|
import { games, gameVersions } from "@/lib/db/schema"
|
||||||
|
import { eq, and } from "drizzle-orm"
|
||||||
|
|
||||||
|
export const gamesLookupRoutes = new Elysia({
|
||||||
|
prefix: "/games",
|
||||||
|
detail: { tags: ["Games"] },
|
||||||
|
}).get(
|
||||||
|
"/lookup",
|
||||||
|
async ({ query, set }) => {
|
||||||
|
const { steamAppId } = query
|
||||||
|
|
||||||
|
if (!steamAppId) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "steamAppId query parameter is required" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the game
|
||||||
|
const [game] = await db
|
||||||
|
.select({
|
||||||
|
id: games.id,
|
||||||
|
steamAppId: games.steamAppId,
|
||||||
|
title: games.title,
|
||||||
|
slug: games.slug,
|
||||||
|
headerImage: games.headerImage,
|
||||||
|
capsuleImage: games.capsuleImage,
|
||||||
|
developer: games.developer,
|
||||||
|
publisher: games.publisher,
|
||||||
|
source: games.source,
|
||||||
|
})
|
||||||
|
.from(games)
|
||||||
|
.where(eq(games.steamAppId, steamAppId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
set.status = 404
|
||||||
|
return { error: `No game found with steamAppId ${steamAppId}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the latest version
|
||||||
|
const [latestVersion] = await db
|
||||||
|
.select({
|
||||||
|
id: gameVersions.id,
|
||||||
|
versionString: gameVersions.versionString,
|
||||||
|
buildId: gameVersions.buildId,
|
||||||
|
isLatest: gameVersions.isLatest,
|
||||||
|
createdAt: gameVersions.createdAt,
|
||||||
|
})
|
||||||
|
.from(gameVersions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(gameVersions.gameId, game.id),
|
||||||
|
eq(gameVersions.isLatest, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
// If no latest version, get the most recent one
|
||||||
|
const version =
|
||||||
|
latestVersion ??
|
||||||
|
(await db
|
||||||
|
.select({
|
||||||
|
id: gameVersions.id,
|
||||||
|
versionString: gameVersions.versionString,
|
||||||
|
buildId: gameVersions.buildId,
|
||||||
|
isLatest: gameVersions.isLatest,
|
||||||
|
createdAt: gameVersions.createdAt,
|
||||||
|
})
|
||||||
|
.from(gameVersions)
|
||||||
|
.where(eq(gameVersions.gameId, game.id))
|
||||||
|
.orderBy(gameVersions.createdAt)
|
||||||
|
.limit(1)
|
||||||
|
.then((rows) => rows[0] ?? null))
|
||||||
|
|
||||||
|
return {
|
||||||
|
game: {
|
||||||
|
...game,
|
||||||
|
steamAppId: game.steamAppId ?? null,
|
||||||
|
},
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: t.Object({
|
||||||
|
steamAppId: t.Numeric(),
|
||||||
|
}),
|
||||||
|
detail: {
|
||||||
|
description:
|
||||||
|
"Look up a game and its latest version by Steam App ID. Used by the DeckyVault Decky Loader plugin to resolve game info before importing benchmarks.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -30,3 +30,5 @@ export { dashboardPublicRoutes } from "./dashboard-public"
|
|||||||
export { cronRoutes } from "./cron"
|
export { cronRoutes } from "./cron"
|
||||||
export { profilePhotoRoutes } from "./profile-photo"
|
export { profilePhotoRoutes } from "./profile-photo"
|
||||||
export { mobileRoutes } from "./mobile"
|
export { mobileRoutes } from "./mobile"
|
||||||
|
export { gamesLookupRoutes } from "./games-lookup"
|
||||||
|
export { performanceImportRoutes } from "./performance-import"
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { Elysia, t } from "elysia"
|
||||||
|
import { db } from "@/lib/db/index"
|
||||||
|
import {
|
||||||
|
performanceEntries,
|
||||||
|
gameVersions,
|
||||||
|
games,
|
||||||
|
hardware,
|
||||||
|
gamePlatformSupport,
|
||||||
|
} from "@/lib/db/schema"
|
||||||
|
import { eq, and } from "drizzle-orm"
|
||||||
|
import { requireAuthWithApiKeyFallback } from "@/lib/auth/api-key-guard"
|
||||||
|
import { recalculatePlayability } from "./playability"
|
||||||
|
|
||||||
|
const VALID_UPSCALER_TYPES = ["none", "fsr", "dlss", "xess", "lsfg", "other"] as const
|
||||||
|
const VALID_FRAME_GEN_METHODS = ["none", "fsr_fg", "dlss_fg", "lsfg", "other"] as const
|
||||||
|
const VALID_ANTICHEAT_STATUSES = ["none", "supported", "unsupported", "unknown"] as const
|
||||||
|
|
||||||
|
type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number]
|
||||||
|
type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number]
|
||||||
|
type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[number]
|
||||||
|
|
||||||
|
export const performanceImportRoutes = new Elysia({
|
||||||
|
prefix: "/performance",
|
||||||
|
detail: { tags: ["Performance"] },
|
||||||
|
}).post(
|
||||||
|
"/import",
|
||||||
|
async ({ body, request, set }) => {
|
||||||
|
// ── Auth: session or API key ──────────────────────────────────
|
||||||
|
const guard = await requireAuthWithApiKeyFallback(request.headers)
|
||||||
|
if (!guard.ok) {
|
||||||
|
set.status = guard.status
|
||||||
|
return { error: guard.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate version (1 only for now) ─────────────────────────
|
||||||
|
if (body.version !== 1) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "Unsupported import format version" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve game version from steamAppId ──────────────────────
|
||||||
|
const steamAppId = body.steamAppId
|
||||||
|
if (!steamAppId) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "steamAppId is required" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [game] = await db
|
||||||
|
.select({ id: games.id })
|
||||||
|
.from(games)
|
||||||
|
.where(eq(games.steamAppId, steamAppId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
set.status = 404
|
||||||
|
return {
|
||||||
|
error: `No game found with steamAppId ${steamAppId}. Submit the game on DeckyVault first.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the latest version, or create one if needed
|
||||||
|
let [version] = await db
|
||||||
|
.select({ id: gameVersions.id })
|
||||||
|
.from(gameVersions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(gameVersions.gameId, game.id),
|
||||||
|
eq(gameVersions.isLatest, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
// Get the most recent version
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: gameVersions.id })
|
||||||
|
.from(gameVersions)
|
||||||
|
.where(eq(gameVersions.gameId, game.id))
|
||||||
|
.orderBy(gameVersions.createdAt)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
version = existing
|
||||||
|
} else {
|
||||||
|
// Create a stub version so we can create the entry
|
||||||
|
const [newVersion] = await db
|
||||||
|
.insert(gameVersions)
|
||||||
|
.values({
|
||||||
|
gameId: game.id,
|
||||||
|
isLatest: true,
|
||||||
|
})
|
||||||
|
.returning({ id: gameVersions.id })
|
||||||
|
version = newVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate hardware ─────────────────────────────────────────
|
||||||
|
const hardwareSlug = body.hardwareSlug
|
||||||
|
const [device] = await db
|
||||||
|
.select({ slug: hardware.slug, deviceType: hardware.deviceType })
|
||||||
|
.from(hardware)
|
||||||
|
.where(eq(hardware.slug, hardwareSlug))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
set.status = 400
|
||||||
|
return {
|
||||||
|
error: `Unknown hardware slug: "${hardwareSlug}". Available devices: see /api/hardware`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate FPS fields ───────────────────────────────────────
|
||||||
|
const fpsAvg = Number(body.fpsAvg)
|
||||||
|
if (isNaN(fpsAvg) || fpsAvg < 1 || fpsAvg > 500) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "fpsAvg must be between 1 and 500" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fpsLow = body.fpsLow != null ? Number(body.fpsLow) : null
|
||||||
|
if (fpsLow !== null && (isNaN(fpsLow) || fpsLow < 0 || fpsLow > 500)) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "fpsLow must be between 0 and 500" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fpsOnePercentLow =
|
||||||
|
body.fpsOnePercentLow != null ? Number(body.fpsOnePercentLow) : null
|
||||||
|
if (
|
||||||
|
fpsOnePercentLow !== null &&
|
||||||
|
(isNaN(fpsOnePercentLow) || fpsOnePercentLow < 0 || fpsOnePercentLow > 500)
|
||||||
|
) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "fpsOnePercentLow must be between 0 and 500" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fpsHigh = body.fpsHigh != null ? Number(body.fpsHigh) : null
|
||||||
|
if (fpsHigh !== null && (isNaN(fpsHigh) || fpsHigh < 0 || fpsHigh > 500)) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "fpsHigh must be between 0 and 500" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate enums ────────────────────────────────────────────
|
||||||
|
const rawUpscalerType = body.upscalerType ?? "none"
|
||||||
|
const upscalerType: UpscalerType = VALID_UPSCALER_TYPES.includes(
|
||||||
|
rawUpscalerType as UpscalerType,
|
||||||
|
)
|
||||||
|
? (rawUpscalerType as UpscalerType)
|
||||||
|
: "none"
|
||||||
|
|
||||||
|
const rawFrameGenMethod = body.frameGenMethod ?? "none"
|
||||||
|
const frameGenMethod: FrameGenMethod = VALID_FRAME_GEN_METHODS.includes(
|
||||||
|
rawFrameGenMethod as FrameGenMethod,
|
||||||
|
)
|
||||||
|
? (rawFrameGenMethod as FrameGenMethod)
|
||||||
|
: "none"
|
||||||
|
|
||||||
|
// ── Validate other numeric fields ─────────────────────────────
|
||||||
|
const tdpWatts = body.tdpWatts != null ? Number(body.tdpWatts) : null
|
||||||
|
if (tdpWatts !== null && (isNaN(tdpWatts) || tdpWatts <= 0)) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "tdpWatts must be greater than 0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTimeSsd = body.loadTimeSsd != null ? Number(body.loadTimeSsd) : null
|
||||||
|
const loadTimeSd = body.loadTimeSd != null ? Number(body.loadTimeSd) : null
|
||||||
|
|
||||||
|
// ── Validate userNotes length ─────────────────────────────────
|
||||||
|
const userNotes = body.userNotes ?? null
|
||||||
|
if (userNotes && typeof userNotes === "string" && userNotes.length > 5000) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: "userNotes must be 5000 characters or less" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create the performance entry ──────────────────────────────
|
||||||
|
const [entry] = await db
|
||||||
|
.insert(performanceEntries)
|
||||||
|
.values({
|
||||||
|
versionId: version.id,
|
||||||
|
hardwareSlug,
|
||||||
|
userId: guard.user.id,
|
||||||
|
fpsAvg,
|
||||||
|
fpsLow,
|
||||||
|
fpsOnePercentLow,
|
||||||
|
fpsHigh,
|
||||||
|
protonVersion: body.protonVersion ?? null,
|
||||||
|
osVersion: body.osVersion ?? null,
|
||||||
|
upscalerType,
|
||||||
|
upscalerVersion: body.upscalerVersion ?? null,
|
||||||
|
frameGenMethod,
|
||||||
|
loadTimeSsd,
|
||||||
|
loadTimeSd,
|
||||||
|
tdpWatts,
|
||||||
|
launchOptions: body.launchOptions ?? null,
|
||||||
|
settingsJson: body.settingsJson ?? null,
|
||||||
|
userNotes,
|
||||||
|
customSystem: body.customSystem ?? false,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
// ── Update / create gamePlatformSupport ──────────────────────
|
||||||
|
const [existingSupport] = await db
|
||||||
|
.select()
|
||||||
|
.from(gamePlatformSupport)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(gamePlatformSupport.gameId, game.id),
|
||||||
|
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existingSupport) {
|
||||||
|
await db
|
||||||
|
.update(gamePlatformSupport)
|
||||||
|
.set({
|
||||||
|
antiCheatRelevant:
|
||||||
|
body.antiCheatRelevant ?? existingSupport.antiCheatRelevant,
|
||||||
|
antiCheatName: body.antiCheatRelevant
|
||||||
|
? (body.antiCheatName ?? existingSupport.antiCheatName)
|
||||||
|
: null,
|
||||||
|
antiCheatStatus: (body.antiCheatStatus ??
|
||||||
|
existingSupport.antiCheatStatus) as AntiCheatStatus,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(gamePlatformSupport.id, existingSupport.id))
|
||||||
|
} else {
|
||||||
|
await db.insert(gamePlatformSupport).values({
|
||||||
|
gameId: game.id,
|
||||||
|
hardwareSlug,
|
||||||
|
isSupported: true,
|
||||||
|
protonStatus: "unknown",
|
||||||
|
antiCheatRelevant: body.antiCheatRelevant ?? false,
|
||||||
|
antiCheatName: body.antiCheatRelevant ? (body.antiCheatName ?? null) : null,
|
||||||
|
antiCheatStatus: (body.antiCheatStatus ?? "unknown") as AntiCheatStatus,
|
||||||
|
playabilityStatus: "unknown",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget playability recalculation
|
||||||
|
recalculatePlayability(game.id).catch((err) =>
|
||||||
|
console.error("Failed to recalculate playability:", err),
|
||||||
|
)
|
||||||
|
|
||||||
|
set.status = 201
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
gameId: game.id,
|
||||||
|
versionId: version.id,
|
||||||
|
createdAt: entry.createdAt.toISOString(),
|
||||||
|
authMethod: guard.keyId ? "api-key" : "session",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
version: t.Number(),
|
||||||
|
steamAppId: t.Number(),
|
||||||
|
hardwareSlug: t.String(),
|
||||||
|
fpsAvg: t.Number(),
|
||||||
|
fpsLow: t.Optional(t.Nullable(t.Number())),
|
||||||
|
fpsOnePercentLow: t.Optional(t.Nullable(t.Number())),
|
||||||
|
fpsHigh: t.Optional(t.Nullable(t.Number())),
|
||||||
|
protonVersion: t.Optional(t.Nullable(t.String())),
|
||||||
|
osVersion: t.Optional(t.Nullable(t.String())),
|
||||||
|
upscalerType: t.Optional(t.String()),
|
||||||
|
upscalerVersion: t.Optional(t.Nullable(t.String())),
|
||||||
|
frameGenMethod: t.Optional(t.String()),
|
||||||
|
tdpWatts: t.Optional(t.Nullable(t.Number())),
|
||||||
|
loadTimeSsd: t.Optional(t.Nullable(t.Number())),
|
||||||
|
loadTimeSd: t.Optional(t.Nullable(t.Number())),
|
||||||
|
launchOptions: t.Optional(t.Nullable(t.String())),
|
||||||
|
settingsJson: t.Optional(t.Nullable(t.Any())),
|
||||||
|
userNotes: t.Optional(t.Nullable(t.String())),
|
||||||
|
customSystem: t.Optional(t.Boolean()),
|
||||||
|
antiCheatRelevant: t.Optional(t.Boolean()),
|
||||||
|
antiCheatName: t.Optional(t.Nullable(t.String())),
|
||||||
|
antiCheatStatus: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
detail: {
|
||||||
|
description:
|
||||||
|
"Import a performance benchmark from a DeckyVault plugin export (.deckyvault.json). " +
|
||||||
|
"Accepts either session cookies or an x-api-key header for authentication. " +
|
||||||
|
"The steamAppId is used to resolve the game and its latest version automatically.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
import { createAuthClient } from 'better-auth/react'
|
import { createAuthClient } from 'better-auth/react'
|
||||||
import { adminClient, emailOTPClient, lastLoginMethodClient } from 'better-auth/client/plugins'
|
import { adminClient, emailOTPClient, lastLoginMethodClient } from 'better-auth/client/plugins'
|
||||||
import { passkeyClient } from '@better-auth/passkey/client'
|
import { passkeyClient } from '@better-auth/passkey/client'
|
||||||
|
import { apiKeyClient } from '@better-auth/api-key/client'
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env.NEXT_PUBLIC_SITE_URL || "https://localhost:3000",
|
baseURL: process.env.NEXT_PUBLIC_SITE_URL || "https://localhost:3000",
|
||||||
@@ -8,7 +9,8 @@ export const authClient = createAuthClient({
|
|||||||
emailOTPClient(),
|
emailOTPClient(),
|
||||||
passkeyClient(),
|
passkeyClient(),
|
||||||
lastLoginMethodClient(),
|
lastLoginMethodClient(),
|
||||||
adminClient()
|
adminClient(),
|
||||||
|
apiKeyClient(),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+14
@@ -2,6 +2,7 @@ import { betterAuth } from 'better-auth'
|
|||||||
import { admin, captcha, emailOTP, lastLoginMethod } from 'better-auth/plugins'
|
import { admin, captcha, emailOTP, lastLoginMethod } from 'better-auth/plugins'
|
||||||
import { passkey } from '@better-auth/passkey'
|
import { passkey } from '@better-auth/passkey'
|
||||||
import { expo } from '@better-auth/expo'
|
import { expo } from '@better-auth/expo'
|
||||||
|
import { apiKey } from '@better-auth/api-key'
|
||||||
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
|
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
|
||||||
import { db } from '@/lib/db/index'
|
import { db } from '@/lib/db/index'
|
||||||
import { ac, admin as adminRole, moderator, contributor, user } from '@/lib/auth/permissions'
|
import { ac, admin as adminRole, moderator, contributor, user } from '@/lib/auth/permissions'
|
||||||
@@ -72,6 +73,19 @@ export const auth = betterAuth({
|
|||||||
defaultRole: 'user',
|
defaultRole: 'user',
|
||||||
adminRoles: ['admin'],
|
adminRoles: ['admin'],
|
||||||
}),
|
}),
|
||||||
|
apiKey({
|
||||||
|
defaultPrefix: 'dv_',
|
||||||
|
requireName: true,
|
||||||
|
keyExpiration: {
|
||||||
|
defaultExpiresIn: null,
|
||||||
|
disableCustomExpiresTime: false,
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
timeWindow: 1000 * 60 * 60, // 1 hour
|
||||||
|
maxRequests: 1000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
expo(),
|
expo(),
|
||||||
],
|
],
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { db } from "@/lib/db/index"
|
||||||
|
import { user } from "@/lib/db/schema/auth"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import type { Session } from "better-auth"
|
||||||
|
|
||||||
|
type User = typeof auth.$Infer.Session.user
|
||||||
|
|
||||||
|
type ApiKeyGuardResult =
|
||||||
|
| { ok: true; user: User; session: Session | null; keyId: string }
|
||||||
|
| { ok: false; error: string; status: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to authenticate a request using an API key from the x-api-key header.
|
||||||
|
* Verifies the key via Better Auth and looks up the user from the database.
|
||||||
|
*/
|
||||||
|
export async function authenticateWithApiKey(
|
||||||
|
requestHeaders: Headers,
|
||||||
|
): Promise<ApiKeyGuardResult> {
|
||||||
|
const apiKey = requestHeaders.get("x-api-key")
|
||||||
|
if (!apiKey) {
|
||||||
|
return { ok: false, error: "Missing x-api-key header", status: 401 }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await auth.api.verifyApiKey({
|
||||||
|
body: {
|
||||||
|
key: apiKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.valid || !result.key) {
|
||||||
|
const errorMessage = String(result.error?.message ?? "Invalid API key")
|
||||||
|
return { ok: false, error: errorMessage, status: 401 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = result.key.referenceId
|
||||||
|
const keyId = result.key.id
|
||||||
|
|
||||||
|
// Look up the user directly from the database
|
||||||
|
const [dbUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return { ok: false, error: "User not found for API key", status: 401 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal user object matching Better Auth's Session.user type
|
||||||
|
const authedUser: User = {
|
||||||
|
id: dbUser.id,
|
||||||
|
name: dbUser.name,
|
||||||
|
email: dbUser.email,
|
||||||
|
emailVerified: dbUser.emailVerified,
|
||||||
|
image: dbUser.image,
|
||||||
|
createdAt: dbUser.createdAt,
|
||||||
|
updatedAt: dbUser.updatedAt,
|
||||||
|
role: dbUser.role ?? "user",
|
||||||
|
banned: dbUser.banned ?? null,
|
||||||
|
banReason: dbUser.banReason ?? null,
|
||||||
|
banExpires: dbUser.banExpires ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
user: authedUser,
|
||||||
|
session: null,
|
||||||
|
keyId,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[api-key-guard] API key verification failed:", err)
|
||||||
|
return { ok: false, error: "API key verification failed", status: 500 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined auth guard: first tries session auth (cookie), then falls back
|
||||||
|
* to API key auth (x-api-key header). Returns the authenticated user.
|
||||||
|
*/
|
||||||
|
export async function requireAuthWithApiKeyFallback(
|
||||||
|
requestHeaders: Headers,
|
||||||
|
): Promise<ApiKeyGuardResult> {
|
||||||
|
// Try session auth first
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders })
|
||||||
|
if (session) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
user: session.user,
|
||||||
|
session: session.session,
|
||||||
|
keyId: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to API key
|
||||||
|
return authenticateWithApiKey(requestHeaders)
|
||||||
|
}
|
||||||
@@ -135,3 +135,46 @@ export const passkeyRelations = relations(passkey, ({ one }) => ({
|
|||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
export const apikey = pgTable(
|
||||||
|
"apikey",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
configId: text("config_id").notNull().default("default"),
|
||||||
|
name: text("name"),
|
||||||
|
start: text("start"),
|
||||||
|
referenceId: text("reference_id").notNull(),
|
||||||
|
prefix: text("prefix"),
|
||||||
|
key: text("key").notNull(),
|
||||||
|
refillInterval: integer("refill_interval"),
|
||||||
|
refillAmount: integer("refill_amount"),
|
||||||
|
lastRefillAt: timestamp("last_refill_at"),
|
||||||
|
enabled: boolean("enabled").default(true).notNull(),
|
||||||
|
rateLimitEnabled: boolean("rate_limit_enabled").default(true).notNull(),
|
||||||
|
rateLimitTimeWindow: integer("rate_limit_time_window"),
|
||||||
|
rateLimitMax: integer("rate_limit_max"),
|
||||||
|
requestCount: integer("request_count").default(0).notNull(),
|
||||||
|
remaining: integer("remaining"),
|
||||||
|
lastRequest: timestamp("last_request"),
|
||||||
|
expiresAt: timestamp("expires_at"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at")
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
permissions: text("permissions"),
|
||||||
|
metadata: text("metadata"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("apikey_config_id_idx").on(table.configId),
|
||||||
|
index("apikey_reference_id_idx").on(table.referenceId),
|
||||||
|
index("apikey_key_idx").on(table.key),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const apikeyRelations = relations(apikey, ({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [apikey.referenceId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1036.0",
|
"@aws-sdk/client-s3": "^3.1036.0",
|
||||||
|
"@better-auth/api-key": "^1.6.22",
|
||||||
"@better-auth/drizzle-adapter": "^1.6.9",
|
"@better-auth/drizzle-adapter": "^1.6.9",
|
||||||
"@better-auth/expo": "^1.6.11",
|
"@better-auth/expo": "^1.6.11",
|
||||||
"@better-auth/passkey": "^1.6.9",
|
"@better-auth/passkey": "^1.6.9",
|
||||||
|
|||||||
Reference in New Issue
Block a user