Skip to main content

fedimint_server_ui/
setup.rs

1use std::collections::BTreeSet;
2
3use axum::Router;
4use axum::extract::State;
5use axum::response::{Html, IntoResponse, Redirect};
6use axum::routing::{get, post};
7use axum_extra::extract::Form;
8use axum_extra::extract::cookie::CookieJar;
9use fedimint_core::core::ModuleKind;
10use fedimint_core::module::ApiAuth;
11use fedimint_server_core::setup_ui::DynSetupApi;
12use fedimint_ui_common::assets::WithStaticRoutesExt;
13use fedimint_ui_common::auth::UserAuth;
14use fedimint_ui_common::{
15    CONNECTIVITY_CHECK_ROUTE, LOGIN_ROUTE, LoginInput, ROOT_ROUTE, UiState,
16    connectivity_check_handler, connectivity_widget, login_form_response,
17};
18use maud::{DOCTYPE, Markup, PreEscaped, html};
19use qrcode::QrCode;
20use serde::Deserialize;
21
22use crate::{common_head, login_submit_response};
23
24// Setup route constants
25pub const FEDERATION_SETUP_ROUTE: &str = "/federation_setup";
26pub const ADD_SETUP_CODE_ROUTE: &str = "/add_setup_code";
27pub const RESET_SETUP_CODES_ROUTE: &str = "/reset_setup_codes";
28pub const START_DKG_ROUTE: &str = "/start_dkg";
29
30#[derive(Debug, Deserialize)]
31pub(crate) struct SetupInput {
32    pub password: String,
33    pub name: String,
34    #[serde(default)]
35    pub is_lead: bool,
36    pub federation_name: String,
37    #[serde(default)]
38    pub federation_size: String,
39    #[serde(default)] // will not be sent if disabled
40    pub enable_base_fees: bool,
41    #[serde(default)] // list of enabled module kinds
42    pub enabled_modules: Vec<String>,
43}
44
45#[derive(Debug, Deserialize)]
46pub(crate) struct PeerInfoInput {
47    pub peer_info: String,
48}
49
50pub fn setup_layout(title: &str, content: Markup) -> Markup {
51    html! {
52        (DOCTYPE)
53        html {
54            head {
55                (common_head(title))
56            }
57            body {
58                div class="container" {
59                    div class="row justify-content-center" {
60                        div class="col-md-8 col-lg-5 narrow-container" {
61                            header class="text-center" {
62                                h1 class="header-title" { "Fedimint Guardian UI" }
63                            }
64
65                            div class="card" {
66                                div class="card-body" {
67                                    (content)
68                                }
69                            }
70                        }
71                    }
72                }
73                (connectivity_widget())
74                script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
75                script src="/assets/html5-qrcode.min.js" {}
76            }
77        }
78    }
79}
80
81// GET handler for the /setup route (display the setup form)
82async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
83    if state.api.setup_code().await.is_some() {
84        return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
85    }
86
87    let available_modules = state.api.available_modules();
88    let default_modules = state.api.default_modules();
89
90    let content = html! {
91        form method="post" action=(ROOT_ROUTE)
92            hx-post=(ROOT_ROUTE) hx-target="#setup-error" hx-swap="innerHTML" {
93            style {
94                r#"
95                .toggle-content {
96                    display: none;
97                }
98
99                .toggle-control:checked ~ .toggle-content {
100                    display: block;
101                }
102
103                #base-fees-warning {
104                    display: block;
105                }
106
107                .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
108                    display: none;
109                }
110
111                .accordion-button {
112                    background-color: #f8f9fa;
113                }
114
115                .accordion-button:not(.collapsed) {
116                    background-color: #f8f9fa;
117                    box-shadow: none;
118                }
119
120                .accordion-button:focus {
121                    box-shadow: none;
122                }
123
124                #modules-warning {
125                    display: none;
126                }
127
128                #modules-list:has(.form-check-input:not(:checked)) ~ #modules-warning {
129                    display: block;
130                }
131                "#
132            }
133
134            div class="form-group mb-4" {
135                input type="text" class="form-control" id="name" name="name" placeholder="Your Guardian Name" required;
136            }
137
138            div class="form-group mb-4" {
139                input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required;
140            }
141
142            div class="alert alert-warning mb-3" style="font-size: 0.875rem;" {
143                "Exactly one guardian must set the global config."
144            }
145
146            div class="form-group mb-4" {
147                input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
148
149                label class="form-check-label ms-2" for="is_lead" {
150                    "Set the global config"
151                }
152
153                div class="toggle-content mt-3" {
154                    input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation Name";
155
156                    div class="form-group mt-3" {
157                        label class="form-label" for="federation_size" {
158                            "Total number of guardians (including you)"
159                        }
160                        input type="number" class="form-control" id="federation_size"
161                            name="federation_size" min="1" max="19";
162                        small class="form-text text-muted" {
163                            "At least 4, or 1. Recommended: 4, 7, 10, 13."
164                        }
165                    }
166
167                    div class="form-check mt-3" {
168                        input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
169
170                        label class="form-check-label" for="enable_base_fees" {
171                            "Enable base fees for this federation"
172                        }
173                    }
174
175                    div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
176                        strong { "Warning: " }
177                        "Base fees discourage spam and wasting storage space. The typical fee is only 1-3 sats per transaction, regardless of the value transferred. We recommend enabling the base fee and it cannot be changed later."
178                    }
179
180                    div class="accordion mt-3" id="modulesAccordion" {
181                        div class="accordion-item" {
182                            h2 class="accordion-header" {
183                                button class="accordion-button collapsed" type="button"
184                                    data-bs-toggle="collapse" data-bs-target="#modulesConfig"
185                                    aria-expanded="false" aria-controls="modulesConfig" {
186                                    "Advanced: Configure Enabled Modules"
187                                }
188                            }
189                            div id="modulesConfig" class="accordion-collapse collapse" data-bs-parent="#modulesAccordion" {
190                                div class="accordion-body" {
191                                    div id="modules-list" {
192                                        @for kind in &available_modules {
193                                            div class="form-check" {
194                                                input type="checkbox" class="form-check-input"
195                                                    id=(format!("module_{}", kind.as_str()))
196                                                    name="enabled_modules"
197                                                    value=(kind.as_str())
198                                                    checked[default_modules.contains(kind)];
199
200                                                label class="form-check-label" for=(format!("module_{}", kind.as_str())) {
201                                                    (kind.as_str())
202                                                    @if !default_modules.contains(kind) {
203                                                        span class="badge bg-warning text-dark ms-2" { "experimental" }
204                                                    }
205                                                }
206                                            }
207                                        }
208                                    }
209
210                                    div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
211                                        "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
212                                    }
213                                }
214                            }
215                        }
216                    }
217                }
218            }
219
220            div id="setup-error" {}
221
222            div class="button-container" {
223                button type="submit" class="btn btn-primary setup-btn" { "Confirm" }
224            }
225        }
226    };
227
228    Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
229}
230
231// POST handler for the /setup route (process the password setup form)
232async fn setup_submit(
233    State(state): State<UiState<DynSetupApi>>,
234    Form(input): Form<SetupInput>,
235) -> impl IntoResponse {
236    // Only use these settings if is_lead is true
237    let federation_name = if input.is_lead {
238        Some(input.federation_name)
239    } else {
240        None
241    };
242
243    let disable_base_fees = if input.is_lead {
244        Some(!input.enable_base_fees)
245    } else {
246        None
247    };
248
249    let enabled_modules = if input.is_lead {
250        let enabled: BTreeSet<ModuleKind> = input
251            .enabled_modules
252            .into_iter()
253            .map(|s| ModuleKind::clone_from_str(&s))
254            .collect();
255
256        Some(enabled)
257    } else {
258        None
259    };
260
261    let federation_size = if input.is_lead {
262        let s = input.federation_size.trim();
263        if s.is_empty() {
264            None
265        } else {
266            match s.parse::<u32>() {
267                Ok(size) => Some(size),
268                Err(_) => {
269                    return Html(
270                        html! {
271                            div class="alert alert-danger" { "Invalid federation size" }
272                        }
273                        .into_string(),
274                    )
275                    .into_response();
276                }
277            }
278        }
279    } else {
280        None
281    };
282
283    match state
284        .api
285        .set_local_parameters(
286            ApiAuth::new(input.password),
287            input.name,
288            federation_name,
289            disable_base_fees,
290            enabled_modules,
291            federation_size,
292        )
293        .await
294    {
295        Ok(_) => ([("HX-Redirect", LOGIN_ROUTE)], Html(String::new())).into_response(),
296        Err(e) => Html(
297            html! {
298                div class="alert alert-danger" { (e.to_string()) }
299            }
300            .into_string(),
301        )
302        .into_response(),
303    }
304}
305
306// GET handler for the /login route (display the login form)
307async fn login_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
308    if state.api.setup_code().await.is_none() {
309        return Redirect::to(ROOT_ROUTE).into_response();
310    }
311
312    login_form_response("Fedimint Guardian Login").into_response()
313}
314
315// POST handler for the /login route (authenticate and set session cookie)
316async fn login_submit(
317    State(state): State<UiState<DynSetupApi>>,
318    jar: CookieJar,
319    Form(input): Form<LoginInput>,
320) -> impl IntoResponse {
321    let auth = match state.api.auth().await {
322        Some(auth) => auth,
323        None => return Redirect::to(ROOT_ROUTE).into_response(),
324    };
325
326    login_submit_response(
327        auth,
328        state.auth_cookie_name,
329        state.auth_cookie_value,
330        jar,
331        input,
332    )
333    .into_response()
334}
335
336// GET handler for the /federation-setup route (main federation management page)
337async fn federation_setup(
338    State(state): State<UiState<DynSetupApi>>,
339    _auth: UserAuth,
340) -> impl IntoResponse {
341    let our_connection_info = state
342        .api
343        .setup_code()
344        .await
345        .expect("Successful authentication ensures that the local parameters have been set");
346
347    let connected_peers = state.api.connected_peers().await;
348    let guardian_name = state.api.guardian_name().await;
349    let federation_size = state.api.federation_size().await;
350    let cfg_federation_name = state.api.cfg_federation_name().await;
351    let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
352    let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
353    let total_guardians = connected_peers.len() + 1;
354    let can_start_dkg = federation_size
355        .map(|expected| total_guardians == expected as usize)
356        .unwrap_or(false);
357
358    let content = html! {
359        @if let Some(ref name) = guardian_name {
360            section class="mb-4" {
361                h4 { "Your name" }
362                p { (name) }
363            }
364        }
365
366        section class="mb-4" {
367            h4 { "Federation settings" }
368            @if cfg_federation_name.is_some() || federation_size.is_some() || cfg_base_fees_disabled.is_some() || cfg_enabled_modules.is_some() {
369                ul class="list-group list-group-flush" {
370                    @if let Some(ref name) = cfg_federation_name {
371                        li class="list-group-item" {
372                            strong { "Federation name: " }
373                            (name)
374                        }
375                    }
376                    @if let Some(size) = federation_size {
377                        li class="list-group-item" {
378                            strong { "Federation size: " }
379                            (size)
380                        }
381                    }
382                    @if let Some(disabled) = cfg_base_fees_disabled {
383                        li class="list-group-item" {
384                            strong { "Base fees: " }
385                            @if disabled { "disabled" } @else { "enabled" }
386                        }
387                    }
388                    @if let Some(ref modules) = cfg_enabled_modules {
389                        li class="list-group-item" {
390                            strong { "Enabled modules: " }
391                            (modules.iter().map(|m| m.as_str().to_owned()).collect::<Vec<_>>().join(", "))
392                        }
393                    }
394                }
395            } @else {
396                p class="text-muted" { "Leader's setup code not provided yet." }
397            }
398        }
399
400        hr class="my-4" {}
401
402        section class="mb-4" {
403            h4 { "Your setup code" }
404
405            p { "Share it with other guardians." }
406
407            @let qr_svg = QrCode::new(&our_connection_info)
408                .expect("Failed to generate QR code")
409                .render::<qrcode::render::svg::Color>()
410                .build();
411
412            div class="text-center mb-3" {
413                div class="border rounded p-2 bg-white d-inline-block" style="width: 250px; max-width: 100%;" {
414                    div style="width: 100%; height: auto; overflow: hidden;" {
415                        (PreEscaped(format!(r#"<div style="width: 100%; height: auto;">{}</div>"#,
416                            qr_svg.replace("width=", "data-width=")
417                                  .replace("height=", "data-height=")
418                                  .replace("<svg", r#"<svg style="width: 100%; height: auto; display: block;""#))))
419                    }
420                }
421            }
422
423            div class="alert alert-info mb-3" {
424                (our_connection_info)
425            }
426
427            div class="text-center" {
428                button type="button" class="btn btn-outline-primary setup-btn"
429                    onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
430                    "Copy to Clipboard"
431                }
432            }
433        }
434
435        hr class="my-4" {}
436
437        section class="mb-4" {
438            h4 { "Other guardians" }
439
440            @if let Some(expected) = federation_size {
441                p { (format!("{total_guardians} of {expected} guardians connected.")) }
442            } @else {
443                p { "Add setup code of every other guardian." }
444            }
445
446            ul class="list-group mb-4" {
447                @for peer in connected_peers {
448                    li class="list-group-item" { (peer) }
449                }
450            }
451
452            form method="post" action=(ADD_SETUP_CODE_ROUTE) {
453                div class="mb-3" {
454                    div class="input-group" {
455                        input type="text" class="form-control" id="peer_info" name="peer_info"
456                            placeholder="Paste setup code" required;
457                        button type="button" class="btn btn-outline-secondary" onclick="startQrScanner()" title="Scan QR Code" {
458                            (PreEscaped(r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M15 12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.172a3 3 0 0 0 2.12-.879l.83-.828A1 1 0 0 1 6.827 3h2.344a1 1 0 0 1 .707.293l.828.828A3 3 0 0 0 12.828 5H14a1 1 0 0 1 1 1zM2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4z"/><path d="M8 11a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5m0 1a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7M3 6.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/></svg>"#))
459                        }
460                    }
461                }
462
463                div class="row mt-3" {
464                    div class="col-6" {
465                        button type="button" class="btn btn-warning w-100" onclick="if(confirm('Are you sure you want to reset all guardians?')){document.getElementById('reset-form').submit();}" {
466                            "Reset Guardians"
467                        }
468                    }
469
470                    div class="col-6" {
471                        button type="submit" class="btn btn-primary w-100"
472                            disabled[can_start_dkg] {
473                            @if can_start_dkg { "List complete" } @else { "Add Guardian" }
474                        }
475                    }
476                }
477            }
478
479            form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
480        }
481
482        hr class="my-4" {}
483
484        section class="mb-4" {
485            div class="alert alert-warning mb-4" {
486                "Verify " b { "all" } " other guardians were added. This process cannot be reversed once started."
487            }
488
489            div class="text-center" {
490                form method="post" action=(START_DKG_ROUTE) {
491                    button type="submit" class="btn btn-warning setup-btn"
492                        disabled[!can_start_dkg] {
493                        "🚀 Confirm"
494                    }
495                }
496                @if !can_start_dkg {
497                    @if let Some(expected) = federation_size {
498                        p class="text-muted mt-2" style="font-size: 0.875rem;" {
499                            (format!("Need to collect {} more setup code(s).", expected as usize - total_guardians))
500                        }
501                    } @else {
502                        p class="text-muted mt-2" style="font-size: 0.875rem;" {
503                            "Need to collect the setup codes from all other guardians."
504                        }
505                    }
506                }
507            }
508        }
509
510        // QR Scanner Modal
511        div class="modal fade" id="qrScannerModal" tabindex="-1" aria-labelledby="qrScannerModalLabel" aria-hidden="true" {
512            div class="modal-dialog modal-dialog-centered" {
513                div class="modal-content" {
514                    div class="modal-header" {
515                        h5 class="modal-title" id="qrScannerModalLabel" { "Scan Setup Code" }
516                        button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
517                    }
518                    div class="modal-body" {
519                        div id="qr-reader" style="width: 100%;" {}
520                        div id="qr-reader-error" class="alert alert-danger mt-3 d-none" {}
521                    }
522                    div class="modal-footer" {
523                        button type="button" class="btn btn-secondary" data-bs-dismiss="modal" { "Cancel" }
524                    }
525                }
526            }
527        }
528
529        // QR Scanner JavaScript
530        script {
531            (PreEscaped(r#"
532            var html5QrCode = null;
533            var qrScannerModal = null;
534
535            function startQrScanner() {
536                // Check for Flutter override hook
537                if (typeof window.fedimintQrScannerOverride === 'function') {
538                    window.fedimintQrScannerOverride(function(result) {
539                        if (result) {
540                            document.getElementById('peer_info').value = result;
541                        }
542                    });
543                    return;
544                }
545
546                var modalEl = document.getElementById('qrScannerModal');
547                qrScannerModal = new bootstrap.Modal(modalEl);
548
549                // Reset error message
550                var errorEl = document.getElementById('qr-reader-error');
551                errorEl.classList.add('d-none');
552                errorEl.textContent = '';
553
554                qrScannerModal.show();
555
556                // Wait for modal to be shown before starting camera
557                modalEl.addEventListener('shown.bs.modal', function onShown() {
558                    modalEl.removeEventListener('shown.bs.modal', onShown);
559                    initializeScanner();
560                });
561
562                // Clean up when modal is hidden
563                modalEl.addEventListener('hidden.bs.modal', function onHidden() {
564                    modalEl.removeEventListener('hidden.bs.modal', onHidden);
565                    stopQrScanner();
566                });
567            }
568
569            function initializeScanner() {
570                html5QrCode = new Html5Qrcode("qr-reader");
571
572                var config = {
573                    fps: 10,
574                    qrbox: { width: 250, height: 250 },
575                    aspectRatio: 1.0
576                };
577
578                html5QrCode.start(
579                    { facingMode: "environment" },
580                    config,
581                    function(decodedText, decodedResult) {
582                        // Success - populate input and close modal
583                        document.getElementById('peer_info').value = decodedText;
584                        qrScannerModal.hide();
585                    },
586                    function(errorMessage) {
587                        // Ignore scan errors (happens constantly while searching)
588                    }
589                ).catch(function(err) {
590                    var errorEl = document.getElementById('qr-reader-error');
591                    errorEl.textContent = 'Unable to access camera: ' + err;
592                    errorEl.classList.remove('d-none');
593                });
594            }
595
596            function stopQrScanner() {
597                if (html5QrCode && html5QrCode.isScanning) {
598                    html5QrCode.stop().catch(function(err) {
599                        console.error('Error stopping scanner:', err);
600                    });
601                }
602            }
603            "#))
604        }
605    };
606
607    Html(setup_layout("Federation Setup", content).into_string()).into_response()
608}
609
610// POST handler for adding peer connection info
611async fn post_add_setup_code(
612    State(state): State<UiState<DynSetupApi>>,
613    _auth: UserAuth,
614    Form(input): Form<PeerInfoInput>,
615) -> impl IntoResponse {
616    match state.api.add_peer_setup_code(input.peer_info).await {
617        Ok(..) => Redirect::to(FEDERATION_SETUP_ROUTE).into_response(),
618        Err(e) => {
619            let content = html! {
620                div class="alert alert-danger" { (e.to_string()) }
621                div class="button-container" {
622                    a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
623                }
624            };
625
626            Html(setup_layout("Error", content).into_string()).into_response()
627        }
628    }
629}
630
631// POST handler for starting the DKG process
632async fn post_start_dkg(
633    State(state): State<UiState<DynSetupApi>>,
634    _auth: UserAuth,
635) -> impl IntoResponse {
636    let our_connection_info = state.api.setup_code().await;
637
638    match state.api.start_dkg().await {
639        Ok(()) => {
640            // Show DKG progress page with htmx polling
641            let content = html! {
642                div class="alert alert-success my-4" {
643                    "Setting up Federation..."
644                }
645
646                p class="text-center" {
647                    "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
648                }
649
650                @if let Some(ref info) = our_connection_info {
651                    hr class="my-4" {}
652                    section class="mb-4" {
653                        h4 { "Your setup code" }
654                        p { "Share with guardians who still need it." }
655                        div class="alert alert-info mb-3" {
656                            (info)
657                        }
658                        div class="text-center" {
659                            button type="button" class="btn btn-outline-primary setup-btn"
660                                onclick=(format!("navigator.clipboard.writeText('{info}')")) {
661                                "Copy to Clipboard"
662                            }
663                        }
664                    }
665                }
666
667                // Hidden div that will poll and redirect when the normal UI is ready
668                div
669                    hx-get=(ROOT_ROUTE)
670                    hx-trigger="every 2s"
671                    hx-swap="none"
672                    hx-on--after-request={
673                        "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
674                    }
675                    style="display: none;"
676                {}
677
678                div class="text-center mt-4" {
679                    div class="spinner-border text-primary" role="status" {
680                        span class="visually-hidden" { "Loading..." }
681                    }
682                    p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
683                }
684            };
685
686            Html(setup_layout("DKG Started", content).into_string()).into_response()
687        }
688        Err(e) => {
689            let content = html! {
690                div class="alert alert-danger" { (e.to_string()) }
691                div class="button-container" {
692                    a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
693                }
694            };
695
696            Html(setup_layout("Error", content).into_string()).into_response()
697        }
698    }
699}
700
701// POST handler for resetting peer connection info
702async fn post_reset_setup_codes(
703    State(state): State<UiState<DynSetupApi>>,
704    _auth: UserAuth,
705) -> impl IntoResponse {
706    state.api.reset_setup_codes().await;
707
708    Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
709}
710
711pub fn router(api: DynSetupApi) -> Router {
712    Router::new()
713        .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
714        .route(LOGIN_ROUTE, get(login_form).post(login_submit))
715        .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
716        .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
717        .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
718        .route(START_DKG_ROUTE, post(post_start_dkg))
719        .route(
720            CONNECTIVITY_CHECK_ROUTE,
721            get(connectivity_check_handler::<DynSetupApi>),
722        )
723        .with_static_routes()
724        .with_state(UiState::new(api))
725}