fix: migrate to tailwind v4 and TS6, add oauth2 dashboard_host setting

- migrate Tailwind v3->v4 (postcss @tailwindcss/postcss, CSS-first @theme/@plugin/@custom-variant)
- fix TS6 build: ignoreDeprecations for baseUrl, react-day-picker v10 classNames, test type fixes
- add dashboard_host setting field with i18n
This commit is contained in:
naiba
2026-06-06 01:39:25 +00:00
parent ee99e93e5e
commit 2ff923220f
23 changed files with 154 additions and 82 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, "@tailwindcss/postcss": {},
autoprefixer: {},
}, },
} }
+1 -1
View File
@@ -70,7 +70,7 @@ function Calendar({
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label, defaultClassNames.caption_label,
), ),
table: "w-full border-collapse", month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
+41 -8
View File
@@ -1,9 +1,43 @@
@tailwind base; @import "tailwindcss";
@tailwind components; @plugin "tailwindcss-animate";
@tailwind utilities;
@layer base { @custom-variant dark (&:is(.dark *));
:root {
@theme inline {
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--text-xsm: 0.825rem;
--text-xsm--line-height: 1.25rem;
}
:root {
--radius: 0.5rem; --radius: 0.5rem;
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 0 0% 3.9%; --foreground: 0 0% 3.9%;
@@ -29,9 +63,9 @@
--chart-3: 197 37% 24%; --chart-3: 197 37% 24%;
--chart-4: 43 74% 66%; --chart-4: 43 74% 66%;
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
} }
.dark { .dark {
--background: 0 0% 9%; --background: 0 0% 9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 0 0% 3.9%; --card: 0 0% 3.9%;
@@ -56,7 +90,6 @@
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%; --chart-5: 340 75% 55%;
}
} }
@layer base { @layer base {
+2
View File
@@ -148,6 +148,8 @@
"Loading": "Laden", "Loading": "Laden",
"Services": "Services", "Services": "Services",
"DashboardOriginalHost": "Agent Verbindungsadresse [Domainname/IP:port]", "DashboardOriginalHost": "Agent Verbindungsadresse [Domainname/IP:port]",
"DashboardHost": "Dashboard-Host für OAuth2-Callback [Domainname/IP:port]",
"DashboardHostHint": "Der öffentliche Host des Dashboards zum Erstellen der OAuth2-Callback-URL. Setzen Sie diesen Wert, wenn das Dashboard über eine andere Domain als die Agent-Verbindungsadresse erreichbar ist. Leer lassen, um dem Request-Host unverändert zu vertrauen.",
"ReservedHosts": "Reservierte Hosts (kommagetrennt; öffentliche/Reverse-Proxy-Domains, die Mitglieder nicht als NAT-Domain nutzen dürfen)", "ReservedHosts": "Reservierte Hosts (kommagetrennt; öffentliche/Reverse-Proxy-Domains, die Mitglieder nicht als NAT-Domain nutzen dürfen)",
"ReservedHostsHint": "Hinter einem Reverse-Proxy sieht das Dashboard seine eigene öffentliche Domain nicht und ist daher nicht automatisch geschützt. Tragen Sie hier jeden öffentlichen Eingangs-Hostnamen ein, sonst könnte ein Mitglied ihn als NAT-Domain registrieren und das Dashboard-Routing übernehmen.", "ReservedHostsHint": "Hinter einem Reverse-Proxy sieht das Dashboard seine eigene öffentliche Domain nicht und ist daher nicht automatisch geschützt. Tragen Sie hier jeden öffentlichen Eingangs-Hostnamen ein, sonst könnte ein Mitglied ihn als NAT-Domain registrieren und das Dashboard-Routing übernehmen.",
"NavigateTo": "Navigieren", "NavigateTo": "Navigieren",
+2
View File
@@ -141,6 +141,8 @@
"WAF": "Web Application Firewall", "WAF": "Web Application Firewall",
"SiteName": "Site Name", "SiteName": "Site Name",
"DashboardOriginalHost": "Agent connecting address [domain name/IP:port]", "DashboardOriginalHost": "Agent connecting address [domain name/IP:port]",
"DashboardHost": "Dashboard host for OAuth2 callback [domain name/IP:port]",
"DashboardHostHint": "The dashboard's public host used to build the OAuth2 callback URL. Set this when the dashboard is reached on a different domain than the agent connecting address. Leave empty to trust the request Host as-is.",
"ReservedHosts": "Reserved hosts (comma-separated; public/reverse-proxy domains members cannot use as NAT domains)", "ReservedHosts": "Reserved hosts (comma-separated; public/reverse-proxy domains members cannot use as NAT domains)",
"ReservedHostsHint": "Behind a reverse proxy the dashboard cannot see its own public domain, so it is not auto-protected. List every public entry hostname here, or a member could register it as a NAT domain and hijack dashboard routing.", "ReservedHostsHint": "Behind a reverse proxy the dashboard cannot see its own public domain, so it is not auto-protected. List every public entry hostname here, or a member could register it as a NAT domain and hijack dashboard routing.",
"ConfigTLS": "Use TLS to connect Agent", "ConfigTLS": "Use TLS to connect Agent",
+2
View File
@@ -157,6 +157,8 @@
"RequestType": "Tipo de solicitud", "RequestType": "Tipo de solicitud",
"RequestBody": "Cuerpo de la solicitud", "RequestBody": "Cuerpo de la solicitud",
"DashboardOriginalHost": "Dirección de conexión del Agent [nombre de dominio/IP:puerto]", "DashboardOriginalHost": "Dirección de conexión del Agent [nombre de dominio/IP:puerto]",
"DashboardHost": "Host del panel para el callback de OAuth2 [nombre de dominio/IP:puerto]",
"DashboardHostHint": "Host público del panel usado para construir la URL de callback de OAuth2. Configúralo cuando se acceda al panel en un dominio distinto al de la dirección de conexión del Agent. Déjalo vacío para confiar en el Host de la petición tal cual.",
"ReservedHosts": "Hosts reservados (separados por comas; dominios públicos/proxy inverso que los miembros no pueden usar como dominio NAT)", "ReservedHosts": "Hosts reservados (separados por comas; dominios públicos/proxy inverso que los miembros no pueden usar como dominio NAT)",
"ReservedHostsHint": "Detrás de un proxy inverso, el panel no ve su propio dominio público, por lo que no se protege automáticamente. Indique aquí todos los nombres de host de entrada públicos; de lo contrario, un miembro podría registrarlo como dominio NAT y secuestrar el enrutamiento del panel.", "ReservedHostsHint": "Detrás de un proxy inverso, el panel no ve su propio dominio público, por lo que no se protege automáticamente. Indique aquí todos los nombres de host de entrada públicos; de lo contrario, un miembro podría registrarlo como dominio NAT y secuestrar el enrutamiento del panel.",
"ConfigTLS": "Usar TLS para conectar el Agent", "ConfigTLS": "Usar TLS para conectar el Agent",
+2
View File
@@ -138,6 +138,8 @@
"WAF": "Firewall Aplikasi Web", "WAF": "Firewall Aplikasi Web",
"SiteName": "Nama Situs", "SiteName": "Nama Situs",
"DashboardOriginalHost": "Alamat koneksi Agen [nama domain/IP:port]", "DashboardOriginalHost": "Alamat koneksi Agen [nama domain/IP:port]",
"DashboardHost": "Host dashboard untuk callback OAuth2 [nama domain/IP:port]",
"DashboardHostHint": "Host publik dashboard yang dipakai untuk membangun URL callback OAuth2. Atur ini bila dashboard diakses pada domain yang berbeda dari alamat koneksi Agen. Kosongkan untuk mempercayai Host permintaan apa adanya.",
"ReservedHosts": "Host khusus (dipisahkan koma; domain publik/reverse-proxy yang tidak boleh dipakai anggota sebagai domain NAT)", "ReservedHosts": "Host khusus (dipisahkan koma; domain publik/reverse-proxy yang tidak boleh dipakai anggota sebagai domain NAT)",
"ReservedHostsHint": "Di belakang reverse proxy, dasbor tidak melihat domain publiknya sendiri sehingga tidak terlindungi otomatis. Cantumkan setiap host entri publik di sini, atau anggota bisa mendaftarkannya sebagai domain NAT dan membajak perutean dasbor.", "ReservedHostsHint": "Di belakang reverse proxy, dasbor tidak melihat domain publiknya sendiri sehingga tidak terlindungi otomatis. Cantumkan setiap host entri publik di sini, atau anggota bisa mendaftarkannya sebagai domain NAT dan membajak perutean dasbor.",
"ConfigTLS": "Gunakan TLS untuk menghubungkan Agen", "ConfigTLS": "Gunakan TLS untuk menghubungkan Agen",
+2
View File
@@ -141,6 +141,8 @@
"CustomCodes": "Codice personalizzato (stili e script)", "CustomCodes": "Codice personalizzato (stili e script)",
"CustomCodesDashboard": "Codice personalizzato per dashboard", "CustomCodesDashboard": "Codice personalizzato per dashboard",
"DashboardOriginalHost": "Indirizzo di ancoraggio dell'agente [nome dominio/IP:porta]", "DashboardOriginalHost": "Indirizzo di ancoraggio dell'agente [nome dominio/IP:porta]",
"DashboardHost": "Host della dashboard per il callback OAuth2 [nome dominio/IP:porta]",
"DashboardHostHint": "Host pubblico della dashboard usato per costruire l'URL di callback OAuth2. Impostalo quando la dashboard è raggiungibile su un dominio diverso dall'indirizzo di connessione dell'agente. Lascia vuoto per fidarti dell'Host della richiesta così com'è.",
"ReservedHosts": "Host riservati (separati da virgola; domini pubblici/reverse-proxy che i membri non possono usare come dominio NAT)", "ReservedHosts": "Host riservati (separati da virgola; domini pubblici/reverse-proxy che i membri non possono usare come dominio NAT)",
"ReservedHostsHint": "Dietro un reverse proxy il pannello non vede il proprio dominio pubblico, quindi non è protetto automaticamente. Elenca qui ogni hostname di ingresso pubblico, altrimenti un membro potrebbe registrarlo come dominio NAT e dirottare il routing del pannello.", "ReservedHostsHint": "Dietro un reverse proxy il pannello non vede il proprio dominio pubblico, quindi non è protetto automaticamente. Elenca qui ogni hostname di ingresso pubblico, altrimenti un membro potrebbe registrarlo come dominio NAT e dirottare il routing del pannello.",
"ConfigTLS": "Usa TLS per connettere Agent", "ConfigTLS": "Usa TLS per connettere Agent",
+2
View File
@@ -140,6 +140,8 @@
"WAF": "Web Application Firewall", "WAF": "Web Application Firewall",
"SiteName": "Numele site-ului", "SiteName": "Numele site-ului",
"DashboardOriginalHost": "Adresa de conectare a agentului [numele domeniului/IP:port]", "DashboardOriginalHost": "Adresa de conectare a agentului [numele domeniului/IP:port]",
"DashboardHost": "Host-ul panoului pentru callback-ul OAuth2 [numele domeniului/IP:port]",
"DashboardHostHint": "Host-ul public al panoului folosit pentru a construi URL-ul de callback OAuth2. Setați-l când panoul este accesat pe un domeniu diferit de adresa de conectare a agentului. Lăsați gol pentru a avea încredere în Host-ul cererii ca atare.",
"ReservedHosts": "Host-uri reservate (separate prin virgulă; domeniile publice sau ale reverse proxy-urilor nu pot fi utilizate ca domenii NAT)", "ReservedHosts": "Host-uri reservate (separate prin virgulă; domeniile publice sau ale reverse proxy-urilor nu pot fi utilizate ca domenii NAT)",
"ReservedHostsHint": "În spatele unui reverse proxy, dashboard-ul nu își poate vedea propriul domeniu public, prin urmare nu este protejat automat. Trebuie să menționați aici toate hostname-urile ale intrărilor publice; în caz contrar, un membru ar putea să le înregistreze ca domenii NAT și să deturneze rutarea către dashboard.", "ReservedHostsHint": "În spatele unui reverse proxy, dashboard-ul nu își poate vedea propriul domeniu public, prin urmare nu este protejat automat. Trebuie să menționați aici toate hostname-urile ale intrărilor publice; în caz contrar, un membru ar putea să le înregistreze ca domenii NAT și să deturneze rutarea către dashboard.",
"ConfigTLS": "Folosește TLS pentru a conecta agentul", "ConfigTLS": "Folosește TLS pentru a conecta agentul",
+2
View File
@@ -42,6 +42,8 @@
"Credential": "Учетные данные", "Credential": "Учетные данные",
"CommunityThemeDescription": "Эта тема предоставлена сообществом, используйте на свой страх и риск", "CommunityThemeDescription": "Эта тема предоставлена сообществом, используйте на свой страх и риск",
"DashboardOriginalHost": "Адрес подключения агента [домен/IP:порт]", "DashboardOriginalHost": "Адрес подключения агента [домен/IP:порт]",
"DashboardHost": "Хост панели для OAuth2 callback [домен/IP:порт]",
"DashboardHostHint": "Публичный хост панели, используемый для формирования URL обратного вызова OAuth2. Укажите его, если панель доступна на домене, отличном от адреса подключения агента. Оставьте пустым, чтобы доверять Host из запроса как есть.",
"ReservedHosts": "Зарезервированные хосты (через запятую; публичные/реверс-прокси домены, которые участники не могут использовать как NAT-домен)", "ReservedHosts": "Зарезервированные хосты (через запятую; публичные/реверс-прокси домены, которые участники не могут использовать как NAT-домен)",
"ReservedHostsHint": "За реверс-прокси панель не видит собственный публичный домен и не защищается автоматически. Укажите здесь все публичные входные хосты, иначе участник сможет зарегистрировать такой домен как NAT-домен и перехватить маршрутизацию панели.", "ReservedHostsHint": "За реверс-прокси панель не видит собственный публичный домен и не защищается автоматически. Укажите здесь все публичные входные хосты, иначе участник сможет зарегистрировать такой домен как NAT-домен и перехватить маршрутизацию панели.",
"Error": "Ошибка", "Error": "Ошибка",
+2
View File
@@ -134,6 +134,8 @@
"WAF": "வலை பயன்பாடு ஃபயர்வால்", "WAF": "வலை பயன்பாடு ஃபயர்வால்",
"SiteName": "தளத்தின் பெயர்", "SiteName": "தளத்தின் பெயர்",
"DashboardOriginalHost": "முகவரியை இணைக்கும் முகவரி [டொமைன் பெயர்/ஐபி: போர்ட்]", "DashboardOriginalHost": "முகவரியை இணைக்கும் முகவரி [டொமைன் பெயர்/ஐபி: போர்ட்]",
"DashboardHost": "OAuth2 கால்பேக்கிற்கான டாஷ்போர்டு ஹோஸ்ட் [டொமைன் பெயர்/ஐபி: போர்ட்]",
"DashboardHostHint": "OAuth2 கால்பேக் URL-ஐ உருவாக்கப் பயன்படும் டாஷ்போர்டின் பொது ஹோஸ்ட். ஏஜெண்ட் இணைப்பு முகவரியிலிருந்து வேறுபட்ட டொமைனில் டாஷ்போர்டு அணுகப்பட்டால் இதை அமைக்கவும். கோரிக்கையின் Host-ஐ அப்படியே நம்ப காலியாக விடவும்.",
"ReservedHosts": "ஒதுக்கப்பட்ட ஹோஸ்ட்கள் (கமாவால் பிரிக்கப்பட்டது; உறுப்பினர்கள் NAT டொமைனாகப் பயன்படுத்த முடியாத பொது/ரிவர்ஸ்-ப்ராக்ஸி டொமைன்கள்)", "ReservedHosts": "ஒதுக்கப்பட்ட ஹோஸ்ட்கள் (கமாவால் பிரிக்கப்பட்டது; உறுப்பினர்கள் NAT டொமைனாகப் பயன்படுத்த முடியாத பொது/ரிவர்ஸ்-ப்ராக்ஸி டொமைன்கள்)",
"ReservedHostsHint": "ரிவர்ஸ்-ப்ராக்ஸிக்குப் பின்னால் டாஷ்போர்டு அதன் சொந்த பொது டொமைனைப் பார்க்க முடியாது, எனவே அது தானாகப் பாதுகாக்கப்படாது. அனைத்து பொது நுழைவு ஹோஸ்ட்களையும் இங்கே பட்டியலிடுங்கள்; இல்லையெனில் ஒரு உறுப்பினர் அதை NAT டொமைனாகப் பதிவுசெய்து டாஷ்போர்டு வழித்தடத்தைக் கடத்தலாம்.", "ReservedHostsHint": "ரிவர்ஸ்-ப்ராக்ஸிக்குப் பின்னால் டாஷ்போர்டு அதன் சொந்த பொது டொமைனைப் பார்க்க முடியாது, எனவே அது தானாகப் பாதுகாக்கப்படாது. அனைத்து பொது நுழைவு ஹோஸ்ட்களையும் இங்கே பட்டியலிடுங்கள்; இல்லையெனில் ஒரு உறுப்பினர் அதை NAT டொமைனாகப் பதிவுசெய்து டாஷ்போர்டு வழித்தடத்தைக் கடத்தலாம்.",
"ConfigTLS": "முகவரை இணைக்க TLS ஐப் பயன்படுத்தவும்", "ConfigTLS": "முகவரை இணைக்க TLS ஐப் பயன்படுத்தவும்",
+2
View File
@@ -154,6 +154,8 @@
"CustomCodes": "自定义代码(样式和脚本)", "CustomCodes": "自定义代码(样式和脚本)",
"CustomCodesDashboard": "仪表板的自定义代码", "CustomCodesDashboard": "仪表板的自定义代码",
"DashboardOriginalHost": "Agent对接地址【域名/IP:端口】", "DashboardOriginalHost": "Agent对接地址【域名/IP:端口】",
"DashboardHost": "面板 OAuth2 回调域名【域名/IP:端口】",
"DashboardHostHint": "用于生成 OAuth2 回调地址的面板对外域名。当面板访问域名与 Agent 对接地址不同时设置此项。留空则信任请求 Host,不做强制重写。",
"ReservedHosts": "保留 Host(逗号分隔;反代/公网域名,成员不可注册为 NAT 域名)", "ReservedHosts": "保留 Host(逗号分隔;反代/公网域名,成员不可注册为 NAT 域名)",
"ReservedHostsHint": "反代部署下面板看不到自身的对外域名,无法自动保护。请在此列出所有公网入口域名,否则成员可将其注册为 NAT 域名抢占面板路由。", "ReservedHostsHint": "反代部署下面板看不到自身的对外域名,无法自动保护。请在此列出所有公网入口域名,否则成员可将其注册为 NAT 域名抢占面板路由。",
"ConfigTLS": "Agent 使用 TLS 连接", "ConfigTLS": "Agent 使用 TLS 连接",
+2
View File
@@ -146,6 +146,8 @@
"CustomCodes": "自訂程式碼(樣式和腳本)", "CustomCodes": "自訂程式碼(樣式和腳本)",
"CustomCodesDashboard": "儀表板的自訂程式碼", "CustomCodesDashboard": "儀表板的自訂程式碼",
"DashboardOriginalHost": "Agent對接位址【網域名稱/IP:連接埠】", "DashboardOriginalHost": "Agent對接位址【網域名稱/IP:連接埠】",
"DashboardHost": "面板 OAuth2 回呼網域【網域名稱/IP:連接埠】",
"DashboardHostHint": "用於產生 OAuth2 回呼位址的面板對外網域。當面板存取網域與 Agent 對接位址不同時設定此項。留空則信任請求 Host,不做強制重寫。",
"ReservedHosts": "保留 Host(逗號分隔;反代/公網網域,成員不可註冊為 NAT 網域)", "ReservedHosts": "保留 Host(逗號分隔;反代/公網網域,成員不可註冊為 NAT 網域)",
"ReservedHostsHint": "反代部署下面板看不到自身的對外網域,無法自動保護。請在此列出所有公網入口網域,否則成員可將其註冊為 NAT 網域搶佔面板路由。", "ReservedHostsHint": "反代部署下面板看不到自身的對外網域,無法自動保護。請在此列出所有公網入口網域,否則成員可將其註冊為 NAT 網域搶佔面板路由。",
"ConfigTLS": "Agent 使用 TLS 連線", "ConfigTLS": "Agent 使用 TLS 連線",
+15
View File
@@ -45,6 +45,7 @@ const settingFormSchema = z.object({
language: z.string().min(2), language: z.string().min(2),
user_template: z.string().min(1), user_template: z.string().min(1),
install_host: asOptionalField(z.string()), install_host: asOptionalField(z.string()),
dashboard_host: asOptionalField(z.string()),
reserved_hosts: asOptionalField(z.string()), reserved_hosts: asOptionalField(z.string()),
custom_code: asOptionalField(z.string()), custom_code: asOptionalField(z.string()),
custom_code_dashboard: asOptionalField(z.string()), custom_code_dashboard: asOptionalField(z.string()),
@@ -298,6 +299,20 @@ export default function SettingsPage() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="dashboard_host"
render={({ field }) => (
<FormItem>
<FormLabel>{t("DashboardHost")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>{t("DashboardHostHint")}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="reserved_hosts" name="reserved_hosts"
+7 -7
View File
@@ -2,10 +2,10 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { createApiToken, deleteApiToken, listApiTokens } from "../api/api-tokens" import { createApiToken, deleteApiToken, listApiTokens } from "../api/api-tokens"
const realFetch = global.fetch const realFetch = globalThis.fetch
function mockFetch(payload: unknown, ok = true, success = true) { function mockFetch(payload: unknown, ok = true, success = true) {
global.fetch = vi.fn(async () => { globalThis.fetch = vi.fn(async () => {
return new Response(JSON.stringify({ success, error: success ? "" : "boom", data: payload }), { return new Response(JSON.stringify({ success, error: success ? "" : "boom", data: payload }), {
status: ok ? 200 : 500, status: ok ? 200 : 500,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -19,13 +19,13 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
global.fetch = realFetch globalThis.fetch = realFetch
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
test("listApiTokens GETs /api/v1/api-tokens and returns parsed array", async () => { test("listApiTokens GETs /api/v1/api-tokens and returns parsed array", async () => {
const calls: Array<{ url: string; method: string }> = [] const calls: Array<{ url: string; method: string }> = []
global.fetch = vi.fn(async (input: any, init?: any) => { globalThis.fetch = vi.fn(async (input: any, init?: any) => {
calls.push({ url: String(input), method: String(init?.method ?? "GET") }) calls.push({ url: String(input), method: String(init?.method ?? "GET") })
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -54,7 +54,7 @@ test("listApiTokens GETs /api/v1/api-tokens and returns parsed array", async ()
test("createApiToken POSTs /api/v1/api-tokens and serializes scopes / server_ids / expires_in_days", async () => { test("createApiToken POSTs /api/v1/api-tokens and serializes scopes / server_ids / expires_in_days", async () => {
let captured: { url: string; method: string; body: any } | null = null let captured: { url: string; method: string; body: any } | null = null
global.fetch = vi.fn(async (input: any, init?: any) => { globalThis.fetch = vi.fn(async (input: any, init?: any) => {
captured = { captured = {
url: String(input), url: String(input),
method: String(init?.method ?? ""), method: String(init?.method ?? ""),
@@ -103,7 +103,7 @@ test("listApiTokens normalizes null scopes to an empty array so the table cannot
// Backend APIToken.Scopes() returns nil for ScopesCSV=="" which JSON-encodes // Backend APIToken.Scopes() returns nil for ScopesCSV=="" which JSON-encodes
// as null; migrated/legacy/hand-edited rows hit this. Without normalization // as null; migrated/legacy/hand-edited rows hit this. Without normalization
// the list page does tok.scopes.map(...) on null and the whole page crashes. // the list page does tok.scopes.map(...) on null and the whole page crashes.
global.fetch = vi.fn(async () => { globalThis.fetch = vi.fn(async () => {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
@@ -122,7 +122,7 @@ test("listApiTokens normalizes null scopes to an empty array so the table cannot
test("deleteApiToken DELETEs /api/v1/api-tokens/:id", async () => { test("deleteApiToken DELETEs /api/v1/api-tokens/:id", async () => {
const calls: Array<{ url: string; method: string }> = [] const calls: Array<{ url: string; method: string }> = []
global.fetch = vi.fn(async (input: any, init?: any) => { globalThis.fetch = vi.fn(async (input: any, init?: any) => {
calls.push({ url: String(input), method: String(init?.method ?? "GET") }) calls.push({ url: String(input), method: String(init?.method ?? "GET") })
return new Response(JSON.stringify({ success: true, data: null }), { return new Response(JSON.stringify({ success: true, data: null }), {
status: 200, status: 200,
+7 -7
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FetcherMethod, fetcher } from "../api/api" import { FetcherMethod, fetcher } from "../api/api"
const realFetch = global.fetch const realFetch = globalThis.fetch
function setCookie(value: string) { function setCookie(value: string) {
Object.defineProperty(document, "cookie", { value, configurable: true }) Object.defineProperty(document, "cookie", { value, configurable: true })
@@ -13,7 +13,7 @@ beforeEach(() => {
}) })
afterEach(() => { afterEach(() => {
global.fetch = realFetch globalThis.fetch = realFetch
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
@@ -40,7 +40,7 @@ function headerOf(init: RequestInit | undefined, name: string): string | null {
test("POST sends X-CSRF-Token mirrored from nz-csrf cookie", async () => { test("POST sends X-CSRF-Token mirrored from nz-csrf cookie", async () => {
setCookie("nz-csrf=abc123; other=1") setCookie("nz-csrf=abc123; other=1")
const seen: { init?: RequestInit }[] = [] const seen: { init?: RequestInit }[] = []
global.fetch = vi.fn(async (_input: any, init?: RequestInit) => { globalThis.fetch = vi.fn(async (_input: any, init?: RequestInit) => {
seen.push({ init }) seen.push({ init })
return jsonOk() return jsonOk()
}) as unknown as typeof fetch }) as unknown as typeof fetch
@@ -53,7 +53,7 @@ test("POST sends X-CSRF-Token mirrored from nz-csrf cookie", async () => {
test("DELETE sends X-CSRF-Token mirrored from nz-csrf cookie", async () => { test("DELETE sends X-CSRF-Token mirrored from nz-csrf cookie", async () => {
setCookie("nz-csrf=del-token") setCookie("nz-csrf=del-token")
const seen: { init?: RequestInit }[] = [] const seen: { init?: RequestInit }[] = []
global.fetch = vi.fn(async (_input: any, init?: RequestInit) => { globalThis.fetch = vi.fn(async (_input: any, init?: RequestInit) => {
seen.push({ init }) seen.push({ init })
return jsonOk() return jsonOk()
}) as unknown as typeof fetch }) as unknown as typeof fetch
@@ -68,7 +68,7 @@ test("auto refresh-token uses POST (backend route is POST)", async () => {
const { FetcherMethod: M, fetcher: f } = await import("../api/api") const { FetcherMethod: M, fetcher: f } = await import("../api/api")
setCookie("nz-jwt=sess; nz-csrf=c") setCookie("nz-jwt=sess; nz-csrf=c")
const seen: { url: string; init?: RequestInit }[] = [] const seen: { url: string; init?: RequestInit }[] = []
global.fetch = vi.fn(async (input: any, init?: RequestInit) => { globalThis.fetch = vi.fn(async (input: any, init?: RequestInit) => {
seen.push({ url: String(input), init }) seen.push({ url: String(input), init })
return jsonOk() return jsonOk()
}) as unknown as typeof fetch }) as unknown as typeof fetch
@@ -83,13 +83,13 @@ test("auto refresh-token uses POST (backend route is POST)", async () => {
// Revoke (DELETE) commonly returns 204 / empty body. The fetcher must not // Revoke (DELETE) commonly returns 204 / empty body. The fetcher must not
// blow up on response.json() of an empty body and must resolve successfully. // blow up on response.json() of an empty body and must resolve successfully.
test("DELETE tolerates an empty 204 response body", async () => { test("DELETE tolerates an empty 204 response body", async () => {
global.fetch = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch globalThis.fetch = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch
await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined() await expect(fetcher(FetcherMethod.DELETE, "/api/v1/api-tokens/9")).resolves.toBeUndefined()
}) })
test("empty 200 body does not throw", async () => { test("empty 200 body does not throw", async () => {
global.fetch = vi.fn( globalThis.fetch = vi.fn(
async () => new Response("", { status: 200 }), async () => new Response("", { status: 200 }),
) as unknown as typeof fetch ) as unknown as typeof fetch
+3 -3
View File
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"
import { FetcherMethod, fetcher } from "../api/api" import { FetcherMethod, fetcher } from "../api/api"
const realFetch = global.fetch const realFetch = globalThis.fetch
beforeEach(() => { beforeEach(() => {
// Avoid the auto refresh-token branch interfering with assertions. // Avoid the auto refresh-token branch interfering with assertions.
@@ -10,7 +10,7 @@ beforeEach(() => {
}) })
afterEach(() => { afterEach(() => {
global.fetch = realFetch globalThis.fetch = realFetch
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
@@ -19,7 +19,7 @@ afterEach(() => {
// DELETE route. The wire-level method must match the requested FetcherMethod. // DELETE route. The wire-level method must match the requested FetcherMethod.
test("fetcher uses HTTP DELETE for FetcherMethod.DELETE", async () => { test("fetcher uses HTTP DELETE for FetcherMethod.DELETE", async () => {
const seen: { url: string; init?: RequestInit }[] = [] const seen: { url: string; init?: RequestInit }[] = []
global.fetch = vi.fn(async (input: any, init?: RequestInit) => { globalThis.fetch = vi.fn(async (input: any, init?: RequestInit) => {
seen.push({ url: String(input), init }) seen.push({ url: String(input), init })
return new Response(JSON.stringify({ success: true, data: null }), { return new Response(JSON.stringify({ success: true, data: null }), {
status: 200, status: 200,
+4 -4
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest" import { afterEach, beforeEach, expect, test, vi } from "vitest"
const realFetch = global.fetch const realFetch = globalThis.fetch
function setCookie(value: string) { function setCookie(value: string) {
Object.defineProperty(document, "cookie", { value, configurable: true }) Object.defineProperty(document, "cookie", { value, configurable: true })
@@ -19,7 +19,7 @@ beforeEach(() => {
}) })
afterEach(() => { afterEach(() => {
global.fetch = realFetch globalThis.fetch = realFetch
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
@@ -31,7 +31,7 @@ test("auto refresh is deferred while nz-csrf cookie is missing", async () => {
const { FetcherMethod, fetcher } = await import("../api/api") const { FetcherMethod, fetcher } = await import("../api/api")
setCookie("nz-jwt=session") // jwt present, but no nz-csrf yet setCookie("nz-jwt=session") // jwt present, but no nz-csrf yet
const urls: string[] = [] const urls: string[] = []
global.fetch = vi.fn(async (input: any) => { globalThis.fetch = vi.fn(async (input: any) => {
urls.push(String(input)) urls.push(String(input))
return jsonOk() return jsonOk()
}) as unknown as typeof fetch }) as unknown as typeof fetch
@@ -46,7 +46,7 @@ test("auto refresh is deferred while nz-csrf cookie is missing", async () => {
test("auto refresh fires after nz-csrf cookie becomes available", async () => { test("auto refresh fires after nz-csrf cookie becomes available", async () => {
const { FetcherMethod, fetcher } = await import("../api/api") const { FetcherMethod, fetcher } = await import("../api/api")
const urls: string[] = [] const urls: string[] = []
global.fetch = vi.fn(async (input: any) => { globalThis.fetch = vi.fn(async (input: any) => {
urls.push(String(input)) urls.push(String(input))
return jsonOk() return jsonOk()
}) as unknown as typeof fetch }) as unknown as typeof fetch
+1 -1
View File
@@ -130,7 +130,7 @@ class MockWebSocket extends EventTarget implements WebSocket {
onerror: ((this: WebSocket, ev: Event) => unknown) | null = null onerror: ((this: WebSocket, ev: Event) => unknown) | null = null
onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null = null
onopen: ((this: WebSocket, ev: Event) => unknown) | null = null onopen: ((this: WebSocket, ev: Event) => unknown) | null = null
readyState = MockWebSocket.CONNECTING readyState: 0 | 1 | 2 | 3 = MockWebSocket.CONNECTING
sent: SentWebSocketData[] = [] sent: SentWebSocketData[] = []
constructor(url: string | URL) { constructor(url: string | URL) {
+1
View File
@@ -21,6 +21,7 @@ class TestResizeObserver implements ResizeObserver {
class TestIntersectionObserver implements IntersectionObserver { class TestIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null readonly root: Element | Document | null = null
readonly rootMargin = "" readonly rootMargin = ""
readonly scrollMargin = ""
readonly thresholds: ReadonlyArray<number> = [] readonly thresholds: ReadonlyArray<number> = []
observe(): void {} observe(): void {}
+2
View File
@@ -719,6 +719,7 @@ export interface ModelSetting {
ignored_ip_notification: string ignored_ip_notification: string
ignored_ip_notification_server_ids: Record<string, boolean> ignored_ip_notification_server_ids: Record<string, boolean>
install_host: string install_host: string
dashboard_host: string
ip_change_notification_group_id: number ip_change_notification_group_id: number
/** 系统语言,默认 zh_CN */ /** 系统语言,默认 zh_CN */
language: string language: string
@@ -745,6 +746,7 @@ export interface ModelSettingForm {
enable_mcp?: boolean enable_mcp?: boolean
ignored_ip_notification?: string ignored_ip_notification?: string
install_host?: string install_host?: string
dashboard_host?: string
reserved_hosts?: string reserved_hosts?: string
/** IP变更提醒的通知组 */ /** IP变更提醒的通知组 */
ip_change_notification_group_id: number ip_change_notification_group_id: number
+1
View File
@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
+1
View File
@@ -9,6 +9,7 @@
} }
], ],
"compilerOptions": { "compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]