feat(web-service): allow custom access token with persisted port and localized start errors
- Persist user-supplied access token and last-used port in app_metadata, falling back to defaults when unset - Atomically guard concurrent starts via compare_exchange with RAII rollback of the running flag - Wrap token and port persistence in a single SeaORM transaction to prevent partial writes - Classify bind errors (port in use, permission denied, address unavailable, invalid address) into stable i18n keys - Localize start-failure messages across all 10 supported languages
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Check, Copy, ExternalLink, Eye, EyeOff } from "lucide-react"
|
||||
import { Check, Copy, ExternalLink, Eye, EyeOff, RefreshCw } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
startWebServer,
|
||||
stopWebServer,
|
||||
getWebServerStatus,
|
||||
getWebServiceConfig,
|
||||
type WebServerInfo,
|
||||
} from "@/lib/api"
|
||||
|
||||
const DEFAULT_PORT = 3080
|
||||
import { openUrl } from "@/lib/platform"
|
||||
|
||||
function AddressCard({ label, value }: { label: string; value: string }) {
|
||||
@@ -36,29 +39,64 @@ function AddressCard({ label, value }: { label: string; value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TokenCard({ label, value }: { label: string; value: string }) {
|
||||
function generateRandomToken() {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID().replace(/-/g, "")
|
||||
}
|
||||
return Array.from({ length: 32 }, () =>
|
||||
Math.floor(Math.random() * 16).toString(16)
|
||||
).join("")
|
||||
}
|
||||
|
||||
function TokenEditor({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (next: string) => void
|
||||
disabled: boolean
|
||||
placeholder: string
|
||||
}) {
|
||||
const t = useTranslations("WebServiceSettings")
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
|
||||
function handleCopy() {
|
||||
if (!value) return
|
||||
navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
const displayValue = revealed
|
||||
? value
|
||||
: "\u2022".repeat(Math.max(value.length, 12))
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
<div className="group relative flex items-center rounded-md border bg-muted/40 px-3 py-2">
|
||||
<code className="min-w-0 flex-1 truncate text-sm select-all">
|
||||
{displayValue}
|
||||
</code>
|
||||
<input
|
||||
type={revealed ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="min-w-0 flex-1 bg-transparent font-mono text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(generateRandomToken())}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title={t("regenerate")}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
@@ -74,7 +112,8 @@ function TokenCard({ label, value }: { label: string; value: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
disabled={!value}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={t("copy")}
|
||||
>
|
||||
{copied ? (
|
||||
@@ -92,16 +131,26 @@ function TokenCard({ label, value }: { label: string; value: string }) {
|
||||
export function WebServiceSettings() {
|
||||
const t = useTranslations("WebServiceSettings")
|
||||
const [status, setStatus] = useState<WebServerInfo | null>(null)
|
||||
const [port, setPort] = useState("3080")
|
||||
const [port, setPort] = useState(String(DEFAULT_PORT))
|
||||
const [token, setToken] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const info = await getWebServerStatus()
|
||||
const [info, savedConfig] = await Promise.all([
|
||||
getWebServerStatus(),
|
||||
getWebServiceConfig().catch(() => ({ token: null, port: null })),
|
||||
])
|
||||
setStatus(info)
|
||||
if (info) {
|
||||
setPort(String(info.port))
|
||||
setToken(info.token)
|
||||
} else {
|
||||
setPort(String(savedConfig.port ?? DEFAULT_PORT))
|
||||
if (savedConfig.token) {
|
||||
setToken(savedConfig.token)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Server status unavailable
|
||||
@@ -112,20 +161,42 @@ export function WebServiceSettings() {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
const startErrorKeys: Record<string, string> = {
|
||||
"web_server.already_running": "errors.alreadyRunning",
|
||||
"web_server.invalid_address": "errors.invalidAddress",
|
||||
"web_server.port_in_use": "errors.portInUse",
|
||||
"web_server.permission_denied": "errors.permissionDenied",
|
||||
"web_server.address_unavailable": "errors.addressUnavailable",
|
||||
"web_server.bind_failed": "errors.bindFailed",
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
setError("")
|
||||
setLoading(true)
|
||||
try {
|
||||
const portNum = parseInt(port, 10) || DEFAULT_PORT
|
||||
const info = await startWebServer({
|
||||
port: parseInt(port, 10) || 3080,
|
||||
port: portNum,
|
||||
token: token.trim() || null,
|
||||
})
|
||||
setStatus(info)
|
||||
setToken(info.token)
|
||||
setPort(String(info.port))
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
const rawMsg =
|
||||
e && typeof e === "object" && "message" in e
|
||||
? (e as { message: string }).message
|
||||
: t("startFailed")
|
||||
setError(msg)
|
||||
? String((e as { message: string }).message)
|
||||
: ""
|
||||
const localKey = startErrorKeys[rawMsg]
|
||||
if (localKey) {
|
||||
setError(
|
||||
t(localKey as Parameters<typeof t>[0], {
|
||||
port: parseInt(port, 10) || DEFAULT_PORT,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
setError(rawMsg || t("startFailed"))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -170,6 +241,16 @@ export function WebServiceSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token config */}
|
||||
<TokenEditor
|
||||
label={t("tokenLabel")}
|
||||
value={token}
|
||||
onChange={setToken}
|
||||
disabled={isRunning}
|
||||
placeholder={t("tokenPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("tokenHint")}</p>
|
||||
|
||||
{/* Start/Stop button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-medium">{t("status")}</label>
|
||||
@@ -194,7 +275,7 @@ export function WebServiceSettings() {
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{/* Connection info */}
|
||||
{/* Addresses (only when running) */}
|
||||
{isRunning && (
|
||||
<div className="space-y-3">
|
||||
{status.addresses.map((addr) => (
|
||||
@@ -204,8 +285,6 @@ export function WebServiceSettings() {
|
||||
value={addr}
|
||||
/>
|
||||
))}
|
||||
<TokenCard label={t("tokenLabel")} value={status.token} />
|
||||
<p className="text-xs text-muted-foreground">{t("tokenHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user