diff --git a/backend/internal/bootstrap/server_lifecycle_test.go b/backend/internal/bootstrap/server_lifecycle_test.go index a8f842a..5e95c82 100644 --- a/backend/internal/bootstrap/server_lifecycle_test.go +++ b/backend/internal/bootstrap/server_lifecycle_test.go @@ -14,7 +14,9 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" + "sync" "testing" "time" @@ -115,7 +117,7 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) { input.DNSVerificationToken = dnsResult.Token var completed CompleteResult post("/api/v1/bootstrap/complete", input, &completed) - if !completed.Completed || completed.ManagementURL != "https://"+net.JoinHostPort(input.WebPublicIP, strings.Split(address, ":")[1])+"/" { + if !completed.Completed || completed.ManagementURL != "https://"+net.JoinHostPort(input.WebPublicIP, strconv.Itoa(input.ServerPort))+"/" { t.Fatal("初始化未返回正确管理地址") } select { @@ -143,6 +145,23 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) { t.Fatal(err) } applicationResult := make(chan error, 1) + var closeOnce sync.Once + closeApplication := func() { + closeOnce.Do(func() { + if err := application.Close(); err != nil { + t.Error(err) + } + select { + case err := <-applicationResult: + if !errors.Is(err, http.ErrServerClosed) { + t.Errorf("正式服务关闭错误:%v", err) + } + case <-time.After(5 * time.Second): + t.Error("正式服务关闭超时") + } + }) + } + defer closeApplication() go func() { applicationResult <- application.HTTP.ListenAndServe() }() // 正式 IP 证书与临时证书不同,只信任本次生成的正式证书。 certificatePEM, err := os.ReadFile(stored.Server.TLSCert) @@ -174,7 +193,6 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) { } time.Sleep(10 * time.Millisecond) } - defer application.Close() for path, expected := range map[string]int{ "/api/v1/bootstrap/status": http.StatusNotFound, "/api/v1/bootstrap/settings": http.StatusNotFound, @@ -189,17 +207,7 @@ func TestHTTPSBootstrapCompletesSQLiteAndStartsApplication(t *testing.T) { t.Fatalf("切换后 %s 状态错误:%d", path, response.StatusCode) } } - if err := application.Close(); err != nil { - t.Fatal(err) - } - select { - case err := <-applicationResult: - if !errors.Is(err, http.ErrServerClosed) { - t.Fatalf("正式服务关闭错误:%v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("正式服务关闭超时") - } + closeApplication() } func TestBootstrapRunCancellationRemovesURLWithoutCreatingBusinessDatabase(t *testing.T) { diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 2d65f8c..d18a606 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -112,6 +112,8 @@ type Server struct { loginInFlight int loginPrunedAt time.Time now func() time.Time + httpMu sync.Mutex + httpStopped bool httpServer *http.Server router *gin.Engine } @@ -221,17 +223,13 @@ func (s *Server) ListenAndServe() error { s.configMu.RLock() cfg := s.config s.configMu.RUnlock() + if !cfg.SetupReady { + return errors.New("初始化未完成,请使用独立 HTTPS 初始化服务") + } address, err := listenAddress(cfg) if err != nil { return err } - s.httpServer = newHTTPServer(address, s.router) - if !cfg.SetupReady { - if s.logger != nil { - s.logger.Info("HTTP 服务启动", "address", s.httpServer.Addr, "https_mode", "初始化明文 HTTP") - } - return s.httpServer.ListenAndServe() - } certPath := strings.TrimSpace(cfg.Server.TLSCert) keyPath := strings.TrimSpace(cfg.Server.TLSKey) if certPath == "" || keyPath == "" { @@ -243,10 +241,22 @@ func (s *Server) ListenAndServe() error { if _, err := os.Stat(filepath.Clean(keyPath)); err != nil { return fmt.Errorf("读取 HTTPS 私钥失败:%w", err) } - if s.logger != nil { - s.logger.Info("HTTPS 服务启动", "address", s.httpServer.Addr, "https_mode", cfg.Server.HTTPSMode) + server := newHTTPServer(address, s.router) + s.httpMu.Lock() + if s.httpStopped { + s.httpMu.Unlock() + return http.ErrServerClosed } - return s.httpServer.ListenAndServeTLS(certPath, keyPath) + if s.httpServer != nil { + s.httpMu.Unlock() + return errors.New("HTTPS 服务已经启动") + } + s.httpServer = server + s.httpMu.Unlock() + if s.logger != nil { + s.logger.Info("HTTPS 服务启动", "address", server.Addr, "https_mode", cfg.Server.HTTPSMode) + } + return server.ListenAndServeTLS(certPath, keyPath) } func newHTTPServer(address string, handler http.Handler) *http.Server { @@ -289,10 +299,14 @@ func isLoopbackHost(host string) bool { } func (s *Server) Shutdown(ctx context.Context) error { - if s.httpServer == nil { + s.httpMu.Lock() + s.httpStopped = true + server := s.httpServer + s.httpMu.Unlock() + if server == nil { return nil } - err := s.httpServer.Shutdown(ctx) + err := server.Shutdown(ctx) if errors.Is(err, http.ErrServerClosed) { return nil } diff --git a/backend/internal/httpapi/setup.go b/backend/internal/httpapi/setup.go index edcaa80..d48df79 100644 --- a/backend/internal/httpapi/setup.go +++ b/backend/internal/httpapi/setup.go @@ -80,6 +80,10 @@ type dnsTestRequest struct { func (s *Server) registerSetupRoutes() { s.router.GET("/api/v1/setup/status", s.setupStatus) + // 正式服务只保留状态查询;旧写接口仅供桌面端本机初始化使用。 + if s.config.SetupReady { + return + } s.router.GET("/api/v1/setup/dns/providers", s.setupDNSProviders) s.router.POST("/api/v1/setup/dns/test", s.requireSameOrigin, s.requireSetupAccess, s.dnsOperation, s.testSetupDNS) s.router.POST("/api/v1/setup", s.requireSameOrigin, s.requireSetupAccess, s.longOperation(12*time.Minute), s.applySetup) diff --git a/backend/internal/httpapi/setup_boundary_test.go b/backend/internal/httpapi/setup_boundary_test.go new file mode 100644 index 0000000..affc420 --- /dev/null +++ b/backend/internal/httpapi/setup_boundary_test.go @@ -0,0 +1,81 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" +) + +func TestReadyServerDoesNotRegisterLegacySetupWrites(t *testing.T) { + server := New(Dependencies{Config: readyTestConfig(t.TempDir())}) + for _, item := range []struct { + method string + path string + status int + }{ + {http.MethodGet, "/api/v1/setup/status", http.StatusOK}, + {http.MethodGet, "/api/v1/setup/dns/providers", http.StatusNotFound}, + {http.MethodPost, "/api/v1/setup/dns/test", http.StatusNotFound}, + {http.MethodPost, "/api/v1/setup", http.StatusNotFound}, + {http.MethodGet, "/api/v1/bootstrap/status", http.StatusNotFound}, + } { + for _, remote := range []string{"127.0.0.1:12345", "203.0.113.20:12345"} { + request := httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`)) + request.RemoteAddr = remote + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != item.status { + t.Fatalf("正式服务路由状态错误:%s %s %d", item.method, item.path, response.Code) + } + } + } +} + +func TestUninitializedServerCannotStartPlainHTTP(t *testing.T) { + server := New(Dependencies{Config: config.Default(t.TempDir())}) + if err := server.ListenAndServe(); err == nil || !strings.Contains(err.Error(), "独立 HTTPS") { + t.Fatalf("未初始化服务应拒绝启动明文监听:%v", err) + } + if server.httpServer != nil { + t.Fatal("未初始化不应创建正式监听服务") + } +} + +func TestShutdownBeforeStartPreventsLaterListener(t *testing.T) { + dir := t.TempDir() + cfg := readyTestConfig(dir) + // 停止状态必须在加载证书和监听端口前生效,夹具不需要可用的证书。 + cfg.Server.TLSCert = filepath.Join(dir, "cert.pem") + cfg.Server.TLSKey = filepath.Join(dir, "key.pem") + for _, path := range []string{cfg.Server.TLSCert, cfg.Server.TLSKey} { + if err := os.WriteFile(path, []byte("test-only"), 0o600); err != nil { + t.Fatal(err) + } + } + server := New(Dependencies{Config: cfg}) + if err := server.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } + var pending sync.WaitGroup + for index := 0; index < 16; index++ { + pending.Go(func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + t.Errorf("关闭后不应重新监听:%v", err) + } + }) + pending.Go(func() { + if err := server.Shutdown(context.Background()); err != nil { + t.Error(err) + } + }) + } + pending.Wait() +} diff --git a/desktop/go.mod b/desktop/go.mod index b1e23f8..7bbccf9 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -66,6 +66,10 @@ require ( github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.205 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect @@ -117,7 +121,7 @@ require ( golang.org/x/tools v0.48.0 // indirect google.golang.org/api v0.288.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect - google.golang.org/grpc v1.82.1 // indirect + google.golang.org/grpc v1.83.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/desktop/go.sum b/desktop/go.sum index 4584725..cebe4e4 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -197,6 +197,14 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.205 h1:YnI2+56S5oxByz8BYChRrsmqTUmCp1Ww2KIzZtF4XOU= github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.205/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -342,10 +350,10 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= @@ -508,16 +516,16 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/frontend/package.json b/frontend/package.json index b8076d9..5b9089a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { - "test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js", + "test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js", "dev": "vite", "build": "vite build", "preview": "vite preview" diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js index 4131ec6..01bc045 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.js +++ b/frontend/src/views/SetupWizard/SetupWizard.js @@ -501,6 +501,7 @@ export function useSetupWizard(props, emit) { } function validateCurrentStep() { + verificationClock.value = Date.now(); if (currentStep.value === 1) synchronizeEmailField(); error.value = ""; resultMessage.value = ""; @@ -713,55 +714,21 @@ export function useSetupWizard(props, emit) { } async function submit() { + verificationClock.value = Date.now(); error.value = ""; resultMessage.value = ""; if (isBootstrap && !database.databaseVerified.value) { error.value = "数据库验证结果无效或已过期,请重新验证。"; return; } - if ( - form.value.admin_password.length < 8 || - form.value.admin_password.length > 12 - ) { - error.value = "超级管理员密码长度必须为 8 至 12 个字符。"; - return; - } - if (form.value.admin_password !== form.value.confirm_password) { - error.value = "两次输入的超级管理员密码不一致。"; - return; - } if (!dnsVerificationValid.value) { error.value = "必须先完成 DNS API 在线验证;验证结果已过期时请重新测试。"; return; } - if (form.value.email_smtp_enabled) { - const smtpPort = Number(form.value.email_smtp_port); - if (!form.value.email_smtp_host.trim()) { - error.value = "启用 SMTP 通知后必须填写 SMTP 主机。"; - return; - } - if (!Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535) { - error.value = "SMTP 端口必须在 1 至 65535 之间。"; - return; - } - if (!form.value.email_smtp_username.trim()) { - error.value = "启用 SMTP 通知后必须填写 SMTP 用户名。"; - return; - } - if (!form.value.email_smtp_password) { - error.value = "启用 SMTP 通知后必须填写 SMTP 密码。"; - return; - } - if (form.value.email_smtp_mode === "plain" && !form.value.email_smtp_allow_plaintext_auth) { - error.value = "SMTP 明文模式必须明确允许明文认证,建议改用 STARTTLS 或隐式 TLS。"; - return; - } - } - try { - emailCredentialPayload(); - } catch (err) { - error.value = err.message || "HTTP 通知凭据格式无效。"; - return; + // 提交前重新检查全部步骤,不能依赖之前点击“下一步”时的校验结果。 + for (let step = 1; step <= 4; step += 1) { + currentStep.value = step; + if (!validateCurrentStep()) return; } const hostname = targetType.value === "hostname" ? form.value.web_hostname.trim() : ""; @@ -833,6 +800,18 @@ export function useSetupWizard(props, emit) { }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "保存初始化配置失败"); + if (isBootstrap) { + let destination; + try { + destination = new URL(result?.management_url); + } catch { + throw new Error("初始化响应不完整,请检查服务器状态后再继续。"); + } + if (result?.completed !== true || destination.protocol !== "https:" || + destination.username || destination.password) { + throw new Error("初始化响应不完整,请检查服务器状态后再继续。"); + } + } completed.value = true; resultMessage.value = isBootstrap ? `初始化配置已保存,正在切换至 ${result.management_url}。请稍候;IP 自签名证书或测试证书需要人工核对。` : result.message || "初始化配置已保存。"; emit("initialized", result); diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue index 841dbdb..cfffa5c 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.vue +++ b/frontend/src/views/SetupWizard/SetupWizard.vue @@ -874,7 +874,7 @@ const { -