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:
xintaofei
2026-04-18 10:18:34 +08:00
parent 32b4c88582
commit fd10494128
16 changed files with 427 additions and 67 deletions

View File

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

View File

@@ -1857,7 +1857,17 @@
"copy": "نسخ",
"addressLabel": "عنوان الوصول",
"tokenLabel": "رمز الوصول",
"tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة"
"tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة",
"tokenPlaceholder": "اتركه فارغاً للتوليد التلقائي",
"regenerate": "إعادة التوليد",
"errors": {
"alreadyRunning": "خدمة الويب قيد التشغيل بالفعل",
"invalidAddress": "تنسيق المضيف أو المنفذ غير صالح",
"portInUse": "المنفذ {port} مستخدم بالفعل. أغلق العملية التي تستخدمه أو اختر منفذاً آخر.",
"permissionDenied": "الصلاحيات غير كافية. استخدم منفذاً أعلى من 1024 أو شغّل التطبيق بصلاحيات أعلى.",
"addressUnavailable": "هذا العنوان غير متاح على هذا الجهاز",
"bindFailed": "فشل ربط العنوان"
}
},
"DirectoryBrowser": {
"title": "تصفح المجلد",

View File

@@ -1857,7 +1857,17 @@
"copy": "Kopieren",
"addressLabel": "Zugriffsadresse",
"tokenLabel": "Zugriffstoken",
"tokenHint": "Geben Sie dieses Token beim ersten Zugriff auf den Web-Client ein"
"tokenHint": "Geben Sie dieses Token beim ersten Zugriff auf den Web-Client ein",
"tokenPlaceholder": "Leer lassen für automatische Generierung",
"regenerate": "Neu generieren",
"errors": {
"alreadyRunning": "Der Web-Dienst läuft bereits",
"invalidAddress": "Host- oder Portformat ungültig",
"portInUse": "Port {port} wird bereits verwendet. Beenden Sie den Prozess oder wählen Sie einen anderen Port.",
"permissionDenied": "Zugriff verweigert. Verwenden Sie einen Port über 1024 oder starten Sie mit höheren Rechten.",
"addressUnavailable": "Die Adresse ist auf diesem Computer nicht verfügbar",
"bindFailed": "Adresse konnte nicht gebunden werden"
}
},
"DirectoryBrowser": {
"title": "Verzeichnis durchsuchen",

View File

@@ -1857,7 +1857,17 @@
"copy": "Copy",
"addressLabel": "Access Address",
"tokenLabel": "Access Token",
"tokenHint": "Enter this token when accessing the Web client for the first time"
"tokenHint": "Enter this token when accessing the Web client for the first time",
"tokenPlaceholder": "Leave empty to auto-generate",
"regenerate": "Regenerate",
"errors": {
"alreadyRunning": "Web service is already running",
"invalidAddress": "Invalid host or port format",
"portInUse": "Port {port} is already in use. Close the process using it or choose another port.",
"permissionDenied": "Permission denied. Try a port above 1024 or run with higher privileges.",
"addressUnavailable": "The address is not available on this machine",
"bindFailed": "Failed to bind address"
}
},
"DirectoryBrowser": {
"title": "Browse Directory",

View File

@@ -1857,7 +1857,17 @@
"copy": "Copiar",
"addressLabel": "Dirección de acceso",
"tokenLabel": "Token de acceso",
"tokenHint": "Ingrese este token al acceder al cliente Web por primera vez"
"tokenHint": "Ingrese este token al acceder al cliente Web por primera vez",
"tokenPlaceholder": "Dejar vacío para generar automáticamente",
"regenerate": "Regenerar",
"errors": {
"alreadyRunning": "El servicio Web ya está en ejecución",
"invalidAddress": "Formato de host o puerto no válido",
"portInUse": "El puerto {port} ya está en uso. Cierre el proceso que lo usa o elija otro puerto.",
"permissionDenied": "Permiso denegado. Utilice un puerto superior a 1024 o ejecute con mayores privilegios.",
"addressUnavailable": "La dirección no está disponible en este equipo",
"bindFailed": "No se pudo enlazar la dirección"
}
},
"DirectoryBrowser": {
"title": "Explorar directorio",

View File

@@ -1857,7 +1857,17 @@
"copy": "Copier",
"addressLabel": "Adresse d'accès",
"tokenLabel": "Token d'accès",
"tokenHint": "Entrez ce token lors du premier accès au client Web"
"tokenHint": "Entrez ce token lors du premier accès au client Web",
"tokenPlaceholder": "Laisser vide pour générer automatiquement",
"regenerate": "Régénérer",
"errors": {
"alreadyRunning": "Le service Web est déjà en cours d'exécution",
"invalidAddress": "Format d'hôte ou de port non valide",
"portInUse": "Le port {port} est déjà utilisé. Fermez le processus qui l'utilise ou choisissez un autre port.",
"permissionDenied": "Permission refusée. Utilisez un port supérieur à 1024 ou exécutez avec des privilèges plus élevés.",
"addressUnavailable": "Cette adresse n'est pas disponible sur cette machine",
"bindFailed": "Échec de la liaison à l'adresse"
}
},
"DirectoryBrowser": {
"title": "Parcourir le répertoire",

View File

@@ -1857,7 +1857,17 @@
"copy": "コピー",
"addressLabel": "アクセスアドレス",
"tokenLabel": "アクセストークン",
"tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください"
"tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください",
"tokenPlaceholder": "空欄の場合は自動生成",
"regenerate": "再生成",
"errors": {
"alreadyRunning": "Web サービスはすでに起動しています",
"invalidAddress": "ホストまたはポートの形式が無効です",
"portInUse": "ポート {port} はすでに使用中です。そのポートを使用しているプロセスを終了するか、別のポートを指定してください",
"permissionDenied": "権限が不足しています。1024 以上のポートを使用するか、より高い権限で実行してください",
"addressUnavailable": "このアドレスはこの端末では利用できません",
"bindFailed": "アドレスのバインドに失敗しました"
}
},
"DirectoryBrowser": {
"title": "ディレクトリを参照",

View File

@@ -1857,7 +1857,17 @@
"copy": "복사",
"addressLabel": "접속 주소",
"tokenLabel": "접속 토큰",
"tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요"
"tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요",
"tokenPlaceholder": "비워두면 자동 생성",
"regenerate": "재생성",
"errors": {
"alreadyRunning": "웹 서비스가 이미 실행 중입니다",
"invalidAddress": "호스트 또는 포트 형식이 올바르지 않습니다",
"portInUse": "포트 {port}가 이미 사용 중입니다. 해당 포트를 사용 중인 프로세스를 종료하거나 다른 포트를 선택하세요",
"permissionDenied": "권한이 부족합니다. 1024 이상의 포트를 사용하거나 더 높은 권한으로 실행하세요",
"addressUnavailable": "이 주소는 현재 시스템에서 사용할 수 없습니다",
"bindFailed": "주소 바인딩에 실패했습니다"
}
},
"DirectoryBrowser": {
"title": "디렉토리 찾아보기",

View File

@@ -1857,7 +1857,17 @@
"copy": "Copiar",
"addressLabel": "Endereço de acesso",
"tokenLabel": "Token de acesso",
"tokenHint": "Insira este token ao acessar o cliente Web pela primeira vez"
"tokenHint": "Insira este token ao acessar o cliente Web pela primeira vez",
"tokenPlaceholder": "Deixe em branco para gerar automaticamente",
"regenerate": "Regenerar",
"errors": {
"alreadyRunning": "O serviço Web já está em execução",
"invalidAddress": "Formato de host ou porta inválido",
"portInUse": "A porta {port} já está em uso. Feche o processo que a utiliza ou escolha outra porta.",
"permissionDenied": "Permissão negada. Use uma porta acima de 1024 ou execute com privilégios mais altos.",
"addressUnavailable": "O endereço não está disponível nesta máquina",
"bindFailed": "Falha ao vincular o endereço"
}
},
"DirectoryBrowser": {
"title": "Explorar diretório",

View File

@@ -1857,7 +1857,17 @@
"copy": "复制",
"addressLabel": "访问地址",
"tokenLabel": "访问 Token",
"tokenHint": "Web 客户端首次访问时需输入此 Token"
"tokenHint": "Web 客户端首次访问时需输入此 Token",
"tokenPlaceholder": "留空则自动生成",
"regenerate": "重新生成",
"errors": {
"alreadyRunning": "Web 服务已在运行",
"invalidAddress": "主机或端口格式无效",
"portInUse": "端口 {port} 已被占用,请关闭占用该端口的程序或更换其他端口",
"permissionDenied": "权限不足,请使用 1024 以上的端口,或以更高权限运行",
"addressUnavailable": "该地址在本机不可用",
"bindFailed": "绑定地址失败"
}
},
"DirectoryBrowser": {
"title": "浏览目录",

View File

@@ -1857,7 +1857,17 @@
"copy": "複製",
"addressLabel": "存取位址",
"tokenLabel": "存取 Token",
"tokenHint": "Web 用戶端首次存取時需輸入此 Token"
"tokenHint": "Web 用戶端首次存取時需輸入此 Token",
"tokenPlaceholder": "留空則自動產生",
"regenerate": "重新產生",
"errors": {
"alreadyRunning": "Web 服務已在執行",
"invalidAddress": "主機或連接埠格式無效",
"portInUse": "連接埠 {port} 已被佔用,請關閉佔用的程式或改用其他連接埠",
"permissionDenied": "權限不足,請使用 1024 以上的連接埠,或以更高權限執行",
"addressUnavailable": "該位址在本機不可用",
"bindFailed": "綁定位址失敗"
}
},
"DirectoryBrowser": {
"title": "瀏覽目錄",

View File

@@ -1477,10 +1477,12 @@ export interface WebServerInfo {
export async function startWebServer(params?: {
port?: number
host?: string
token?: string | null
}): Promise<WebServerInfo> {
return getTransport().call("start_web_server", {
port: params?.port ?? null,
host: params?.host ?? null,
token: params?.token ?? null,
})
}
@@ -1492,6 +1494,15 @@ export async function getWebServerStatus(): Promise<WebServerInfo | null> {
return getTransport().call("get_web_server_status")
}
export interface WebServiceConfig {
token: string | null
port: number | null
}
export async function getWebServiceConfig(): Promise<WebServiceConfig> {
return getTransport().call("get_web_service_config")
}
// ─── Chat Channels ───
export async function listChatChannels(): Promise<ChatChannelInfo[]> {