Skip to main content

fedimint_server_ui/
setup.rs

1use std::collections::BTreeSet;
2
3use axum::Router;
4use axum::extract::{DefaultBodyLimit, Multipart, State};
5use axum::http::StatusCode;
6use axum::response::{Html, IntoResponse, Redirect};
7use axum::routing::{get, post};
8use axum_extra::extract::Form;
9use axum_extra::extract::cookie::CookieJar;
10use fedimint_core::core::ModuleKind;
11use fedimint_core::module::ApiAuth;
12use fedimint_server_core::setup_ui::DynSetupApi;
13use fedimint_ui_common::assets::WithStaticRoutesExt;
14use fedimint_ui_common::auth::UserAuth;
15use fedimint_ui_common::{
16    CONNECTIVITY_CHECK_ROUTE, LOGIN_ROUTE, LoginInput, ROOT_ROUTE, UiState,
17    connectivity_check_handler, copiable_text, login_form, login_submit_response,
18    single_card_layout, single_card_layout_with_version,
19};
20use maud::{Markup, PreEscaped, html};
21use qrcode::QrCode;
22use serde::Deserialize;
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";
29pub const START_FEDERATION_ROUTE: &str = "/start_federation";
30pub const RESTORE_GUARDIAN_ROUTE: &str = "/restore_guardian";
31const RESTORE_BACKUP_UPLOAD_LIMIT_BYTES: usize = 10 * 1024 * 1024;
32
33#[derive(Debug, Deserialize)]
34pub(crate) struct SetupInput {
35    pub password: String,
36    pub name: String,
37    #[serde(default)]
38    pub is_lead: bool,
39    pub federation_name: String,
40    #[serde(default)]
41    pub federation_size: String,
42    #[serde(default)] // will not be sent if disabled
43    pub enable_base_fees: bool,
44    #[serde(default)] // list of enabled module kinds
45    pub enabled_modules: Vec<String>,
46}
47
48#[derive(Debug, Deserialize)]
49pub(crate) struct PeerInfoInput {
50    pub peer_info: String,
51}
52
53fn peer_list_section(
54    connected_peers: &[String],
55    federation_size: Option<u32>,
56    cfg_federation_name: &Option<String>,
57    cfg_base_fees_disabled: Option<bool>,
58    cfg_enabled_modules: &Option<BTreeSet<ModuleKind>>,
59    error: Option<&str>,
60) -> Markup {
61    let total_guardians = connected_peers.len() + 1;
62    let can_start_dkg = federation_size
63        .map(|expected| total_guardians == expected as usize)
64        .unwrap_or(false);
65
66    html! {
67        div id="peer-list-section" {
68            @if let Some(expected) = federation_size {
69                p { (format!("{total_guardians} of {expected} guardians connected.")) }
70            } @else {
71                p { "Add setup code for every other guardian." }
72            }
73
74            @if !connected_peers.is_empty() {
75                ul class="list-group mb-2" {
76                    @for peer in connected_peers {
77                        li class="list-group-item" { (peer) }
78                    }
79                }
80
81                form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
82                div class="text-center mb-4" {
83                    button type="button" class="btn btn-link text-danger text-decoration-none p-0" onclick="if(confirm('Are you sure you want to reset all guardians?')){document.getElementById('reset-form').submit();}" {
84                        "Reset Guardians"
85                    }
86                }
87            }
88
89            @if can_start_dkg {
90                // All guardians connected — show confirm form
91                @let has_settings = cfg_federation_name.is_some()
92                    || federation_size.is_some()
93                    || cfg_base_fees_disabled.is_some()
94                    || cfg_enabled_modules.is_some();
95
96                form id="start-dkg-form" hx-post=(START_DKG_ROUTE) hx-target="#peer-list-section" hx-swap="outerHTML" {
97                    @if let Some(error) = error {
98                        div class="alert alert-danger mb-3" { (error) }
99                    }
100                    button type="submit" class="btn btn-warning w-100 py-2" { "Confirm" }
101                }
102
103                @if has_settings {
104                    p class="text-muted mt-3 mb-0" style="font-size: 0.85rem;" {
105                        @if let Some(name) = cfg_federation_name {
106                            (name) " federation has been configured"
107                        } @else {
108                            "The federation has been configured"
109                        }
110                        @if let Some(disabled) = cfg_base_fees_disabled {
111                            " with base fees "
112                            @if disabled { "disabled" } @else { "enabled" }
113                        }
114                        @if let Some(modules) = cfg_enabled_modules {
115                            " and modules "
116                            (modules.iter().map(|m| m.as_str().to_owned()).collect::<Vec<_>>().join(", "))
117                        }
118                        "."
119                    }
120                }
121            } @else {
122                // Still collecting — show add guardian form
123                form id="add-setup-code-form" hx-post=(ADD_SETUP_CODE_ROUTE) hx-target="#peer-list-section" hx-swap="outerHTML" {
124                    div class="mb-3" {
125                        div class="input-group" {
126                            input type="text" class="form-control" id="peer_info" name="peer_info"
127                                placeholder="Paste Setup Code" required;
128                            button type="button" class="btn btn-outline-secondary" onclick="startQrScanner()" title="Scan QR Code" {
129                                i class="bi bi-qr-code-scan" {}
130                            }
131                        }
132                    }
133
134                    @if let Some(error) = error {
135                        div class="alert alert-danger mb-3" { (error) }
136                    }
137                    button type="submit" class="btn btn-primary w-100 py-2" { "Add Guardian" }
138                }
139            }
140        }
141    }
142}
143
144fn setup_error_message(error: &str) -> Markup {
145    html! {
146        div class="alert alert-danger mb-3" { (error) }
147    }
148}
149
150fn setup_choice_content(error: Option<&str>) -> Markup {
151    html! {
152        @if let Some(error) = error {
153            (setup_error_message(error))
154        }
155
156        div class="d-grid gap-3" {
157            a href=(START_FEDERATION_ROUTE) class="btn btn-primary w-100 py-2" {
158                "Start new Federation"
159            }
160
161            a href=(RESTORE_GUARDIAN_ROUTE) class="btn btn-outline-secondary w-100 py-2" {
162                "Restore from backup"
163            }
164        }
165    }
166}
167
168fn restore_form_content(error: Option<&str>) -> Markup {
169    html! {
170        @if let Some(error) = error {
171            (setup_error_message(error))
172        }
173
174        p class="text-muted" {
175            "Upload a guardian backup tar file and enter the guardian password used when the backup was created."
176        }
177
178        form method="post" action=(RESTORE_GUARDIAN_ROUTE) enctype="multipart/form-data" {
179            div class="form-group mb-3" {
180                input type="password" class="form-control" name="password" placeholder="Guardian Password" required;
181            }
182            div class="form-group mb-3" {
183                input type="file" class="form-control" name="backup" accept="application/x-tar,.tar" required;
184            }
185            button type="submit" class="btn btn-primary w-100 py-2" {
186                "Restore Guardian"
187            }
188        }
189
190        div class="text-center mt-3" {
191            a href=(ROOT_ROUTE) class="btn btn-link text-muted text-decoration-none" {
192                "Back"
193            }
194        }
195    }
196}
197
198fn restore_error_response(error: impl AsRef<str>) -> axum::response::Response {
199    (
200        StatusCode::BAD_REQUEST,
201        Html(
202            single_card_layout(
203                "Restore Guardian",
204                restore_form_content(Some(error.as_ref())),
205            )
206            .into_string(),
207        ),
208    )
209        .into_response()
210}
211
212fn setup_form_content(
213    available_modules: &BTreeSet<ModuleKind>,
214    default_modules: &BTreeSet<ModuleKind>,
215) -> Markup {
216    html! {
217        form id="setup-form" hx-post=(ROOT_ROUTE) hx-target="#setup-error" hx-swap="innerHTML" {
218            style {
219                r#"
220                .toggle-content {
221                    display: none;
222                }
223
224                .toggle-control:checked ~ .toggle-content {
225                    display: block;
226                }
227
228                #base-fees-warning {
229                    display: block;
230                }
231
232                .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
233                    display: none;
234                }
235
236                .accordion-button {
237                    background-color: #f8f9fa;
238                }
239
240                .accordion-button:not(.collapsed) {
241                    background-color: #f8f9fa;
242                    box-shadow: none;
243                }
244
245                .accordion-button:focus {
246                    box-shadow: none;
247                }
248
249                #modules-warning {
250                    display: none;
251                }
252
253                #modules-list:has(.form-check-input:not(:checked)) ~ #modules-warning {
254                    display: block;
255                }
256                "#
257            }
258
259            div class="form-group mb-4" {
260                input type="text" class="form-control" id="name" name="name" placeholder="Your Guardian Name" required;
261            }
262
263            div class="form-group mb-4" {
264                input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required;
265            }
266
267            div class="alert alert-warning mb-3" style="font-size: 0.875rem;" {
268                "Exactly one guardian must set the global config."
269            }
270
271            div class="form-group mb-4" {
272                input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
273
274                label class="form-check-label ms-2" for="is_lead" {
275                    "Set the global config"
276                }
277
278                div class="toggle-content mt-3" {
279                    input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation Name";
280
281                    div class="form-group mt-3" {
282                        label class="form-label" for="federation_size" {
283                            "Total number of guardians (including you)"
284                        }
285                        select class="form-select" id="federation_size" name="federation_size" {
286                            option value="" selected disabled { "Federation Size" }
287                            option value="1" { "1 — Testing" }
288                            option value="4" { "4 — Recommended" }
289                            option value="5" { "5" }
290                            option value="6" { "6" }
291                            option value="7" { "7 — Recommended" }
292                            option value="8" { "8" }
293                            option value="9" { "9" }
294                            option value="10" { "10 — Recommended" }
295                            option value="11" { "11" }
296                            option value="12" { "12" }
297                            option value="13" { "13 — Recommended" }
298                            option value="14" { "14" }
299                            option value="15" { "15" }
300                            option value="16" { "16 — Recommended" }
301                            option value="17" { "17" }
302                            option value="18" { "18" }
303                            option value="19" { "19 — Recommended" }
304                            option value="20" { "20" }
305                        }
306                    }
307
308                    div class="form-check mt-3" {
309                        input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
310
311                        label class="form-check-label" for="enable_base_fees" {
312                            "Enable base fees for this federation"
313                        }
314                    }
315
316                    div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
317                        strong { "Warning: " }
318                        "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."
319                    }
320
321                    div class="accordion mt-3" id="modulesAccordion" {
322                        div class="accordion-item" {
323                            h2 class="accordion-header" {
324                                button class="accordion-button collapsed" type="button"
325                                    data-bs-toggle="collapse" data-bs-target="#modulesConfig"
326                                    aria-expanded="false" aria-controls="modulesConfig" {
327                                    "Advanced: Configure Enabled Modules"
328                                }
329                            }
330                            div id="modulesConfig" class="accordion-collapse collapse" data-bs-parent="#modulesAccordion" {
331                                div class="accordion-body" {
332                                    div id="modules-list" {
333                                        @for kind in available_modules {
334                                            div class="form-check" {
335                                                input type="checkbox" class="form-check-input"
336                                                    id=(format!("module_{}", kind.as_str()))
337                                                    name="enabled_modules"
338                                                    value=(kind.as_str())
339                                                    checked[default_modules.contains(kind)];
340
341                                                label class="form-check-label" for=(format!("module_{}", kind.as_str())) {
342                                                    (kind.as_str())
343                                                    @if !default_modules.contains(kind) {
344                                                        span class="badge bg-warning text-dark ms-2" { "experimental" }
345                                                    }
346                                                }
347                                            }
348                                        }
349                                    }
350
351                                    div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
352                                        "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
353                                    }
354                                }
355                            }
356                        }
357                    }
358                }
359            }
360
361            div id="setup-error" {}
362            button type="submit" class="btn btn-primary w-100 py-2" { "Confirm" }
363        }
364    }
365}
366
367// GET handler for the / route (choose setup or restore)
368async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
369    if state.api.setup_code().await.is_some() {
370        return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
371    }
372
373    Html(single_card_layout("Guardian Setup", setup_choice_content(None)).into_string())
374        .into_response()
375}
376
377// GET handler for starting a new federation
378async fn start_federation_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
379    if state.api.setup_code().await.is_some() {
380        return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
381    }
382
383    let available_modules = state.api.available_modules();
384    let default_modules = state.api.default_modules();
385    let content = setup_form_content(&available_modules, &default_modules);
386    let version = state.api.fedimintd_version().await;
387    let version_hash = state.api.fedimintd_version_hash().await;
388
389    Html(
390        single_card_layout_with_version(
391            "Guardian Setup",
392            content,
393            &version,
394            version_hash.as_deref(),
395        )
396        .into_string(),
397    )
398    .into_response()
399}
400
401// POST handler for the /setup route (process the setup form)
402async fn setup_submit(
403    State(state): State<UiState<DynSetupApi>>,
404    Form(input): Form<SetupInput>,
405) -> impl IntoResponse {
406    // Only use these settings if is_lead is true
407    let federation_name = if input.is_lead {
408        Some(input.federation_name)
409    } else {
410        None
411    };
412
413    let disable_base_fees = if input.is_lead {
414        Some(!input.enable_base_fees)
415    } else {
416        None
417    };
418
419    let enabled_modules = if input.is_lead {
420        let enabled: BTreeSet<ModuleKind> = input
421            .enabled_modules
422            .into_iter()
423            .map(|s| ModuleKind::clone_from_str(&s))
424            .collect();
425
426        Some(enabled)
427    } else {
428        None
429    };
430
431    let federation_size = if input.is_lead {
432        let s = input.federation_size.trim();
433        if s.is_empty() {
434            None
435        } else {
436            match s.parse::<u32>() {
437                Ok(size) => Some(size),
438                Err(_) => {
439                    return Html(setup_error_message("Invalid federation size").into_string())
440                        .into_response();
441                }
442            }
443        }
444    } else {
445        None
446    };
447
448    match state
449        .api
450        .set_local_parameters(
451            ApiAuth::new(input.password),
452            input.name,
453            federation_name,
454            disable_base_fees,
455            enabled_modules,
456            federation_size,
457        )
458        .await
459    {
460        Ok(_) => (
461            [("HX-Redirect", FEDERATION_SETUP_ROUTE)],
462            Html(String::new()),
463        )
464            .into_response(),
465        Err(e) => Html(setup_error_message(&e.to_string()).into_string()).into_response(),
466    }
467}
468
469// GET handler for restoring from backup
470async fn restore_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
471    if state.api.setup_code().await.is_some() {
472        return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
473    }
474
475    Html(single_card_layout("Restore Guardian", restore_form_content(None)).into_string())
476        .into_response()
477}
478
479async fn restore_submit(
480    State(state): State<UiState<DynSetupApi>>,
481    mut multipart: Multipart,
482) -> impl IntoResponse {
483    let mut password = None;
484    let mut backup = None;
485
486    loop {
487        let field = match multipart.next_field().await {
488            Ok(Some(field)) => field,
489            Ok(None) => break,
490            Err(e) => return restore_error_response(format!("Failed to read upload: {e}")),
491        };
492
493        match field.name() {
494            Some("password") => match field.text().await {
495                Ok(value) => password = Some(value),
496                Err(e) => return restore_error_response(format!("Failed to read password: {e}")),
497            },
498            Some("backup") => match field.bytes().await {
499                // The setup UI is a local guardian-owner interface. We cap the upload size to
500                // catch accidental oversized requests, but treat malicious tar expansion by the
501                // uploading user as out of scope: they already control this guardian instance.
502                Ok(value) => backup = Some(value.to_vec()),
503                Err(e) => return restore_error_response(format!("Failed to read backup: {e}")),
504            },
505            _ => {}
506        }
507    }
508
509    let Some(password) = password else {
510        return restore_error_response("Missing guardian password");
511    };
512    let Some(backup) = backup else {
513        return restore_error_response("Missing guardian backup file");
514    };
515
516    match state.api.restore_from_backup(password, backup).await {
517        Ok(()) => {
518            let content = html! {
519                div class="alert alert-success mb-3" {
520                    "Guardian backup restored. The server is starting consensus."
521                }
522                div class="text-center mt-4" {
523                    div class="spinner-border text-primary" role="status" {
524                        span class="visually-hidden" { "Loading..." }
525                    }
526                    p class="mt-2 text-muted" { "Waiting for dashboard..." }
527                }
528                div
529                    hx-get=(ROOT_ROUTE)
530                    hx-trigger="every 2s"
531                    hx-swap="none"
532                    hx-on--after-request={
533                        "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
534                    }
535                    style="display: none;"
536                {}
537            };
538            Html(single_card_layout("Guardian Restored", content).into_string()).into_response()
539        }
540        Err(e) => restore_error_response(e.to_string()),
541    }
542}
543
544// GET handler for the /login route (display the login form)
545async fn login_form_handler(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
546    if state.api.setup_code().await.is_none() {
547        return Redirect::to(ROOT_ROUTE).into_response();
548    }
549
550    let version = state.api.fedimintd_version().await;
551    let version_hash = state.api.fedimintd_version_hash().await;
552    Html(
553        single_card_layout_with_version(
554            "Enter Password",
555            login_form(None),
556            &version,
557            version_hash.as_deref(),
558        )
559        .into_string(),
560    )
561    .into_response()
562}
563
564// POST handler for the /login route (authenticate and set session cookie)
565async fn login_submit(
566    State(state): State<UiState<DynSetupApi>>,
567    jar: CookieJar,
568    Form(input): Form<LoginInput>,
569) -> impl IntoResponse {
570    let auth = match state.api.auth().await {
571        Some(auth) => auth,
572        None => return Redirect::to(ROOT_ROUTE).into_response(),
573    };
574
575    login_submit_response(
576        auth,
577        state.auth_cookie_name,
578        state.auth_cookie_value,
579        jar,
580        input,
581    )
582    .into_response()
583}
584
585// GET handler for the /federation-setup route (main federation management page)
586async fn federation_setup(
587    State(state): State<UiState<DynSetupApi>>,
588    _auth: UserAuth,
589) -> impl IntoResponse {
590    let our_connection_info = state
591        .api
592        .setup_code()
593        .await
594        .expect("Successful authentication ensures that the local parameters have been set");
595
596    let version = state.api.fedimintd_version().await;
597    let version_hash = state.api.fedimintd_version_hash().await;
598    let connected_peers = state.api.connected_peers().await;
599    let federation_size = state.api.federation_size().await;
600    let cfg_federation_name = state.api.cfg_federation_name().await;
601    let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
602    let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
603
604    let content = html! {
605        p { "Share this with your fellow guardians." }
606
607        @let qr_svg = QrCode::new(&our_connection_info)
608            .expect("Failed to generate QR code")
609            .render::<qrcode::render::svg::Color>()
610            .build();
611
612        div class="text-center mb-3" {
613            div class="border rounded p-2 bg-white d-inline-block" style="width: 250px; max-width: 100%;" {
614                div style="width: 100%; height: auto; overflow: hidden;" {
615                    (PreEscaped(format!(r#"<div style="width: 100%; height: auto;">{}</div>"#,
616                        qr_svg.replace("width=", "data-width=")
617                              .replace("height=", "data-height=")
618                              .replace("<svg", r#"<svg style="width: 100%; height: auto; display: block;""#))))
619                }
620            }
621        }
622
623        div class="mb-4" {
624            (copiable_text(&our_connection_info))
625        }
626
627        (peer_list_section(&connected_peers, federation_size, &cfg_federation_name, cfg_base_fees_disabled, &cfg_enabled_modules, None))
628
629        // QR Scanner Modal
630        div class="modal fade" id="qrScannerModal" tabindex="-1" aria-labelledby="qrScannerModalLabel" aria-hidden="true" {
631            div class="modal-dialog modal-dialog-centered" {
632                div class="modal-content" {
633                    div class="modal-header" {
634                        h5 class="modal-title" id="qrScannerModalLabel" { "Scan Setup Code" }
635                        button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
636                    }
637                    div class="modal-body" {
638                        div id="qr-reader" style="width: 100%;" {}
639                        div id="qr-reader-error" class="alert alert-danger mt-3 d-none" {}
640                    }
641                    div class="modal-footer" {
642                        button type="button" class="btn btn-secondary" data-bs-dismiss="modal" { "Cancel" }
643                    }
644                }
645            }
646        }
647
648        script src="/assets/html5-qrcode.min.js" {}
649
650        // QR Scanner JavaScript
651        script {
652            (PreEscaped(r#"
653            var html5QrCode = null;
654            var qrScannerModal = null;
655
656            function startQrScanner() {
657                // Check for Flutter override hook
658                if (typeof window.fedimintQrScannerOverride === 'function') {
659                    window.fedimintQrScannerOverride(function(result) {
660                        if (result) {
661                            document.getElementById('peer_info').value = result;
662                        }
663                    });
664                    return;
665                }
666
667                var modalEl = document.getElementById('qrScannerModal');
668                qrScannerModal = new bootstrap.Modal(modalEl);
669
670                // Reset error message
671                var errorEl = document.getElementById('qr-reader-error');
672                errorEl.classList.add('d-none');
673                errorEl.textContent = '';
674
675                qrScannerModal.show();
676
677                // Wait for modal to be shown before starting camera
678                modalEl.addEventListener('shown.bs.modal', function onShown() {
679                    modalEl.removeEventListener('shown.bs.modal', onShown);
680                    initializeScanner();
681                });
682
683                // Clean up when modal is hidden
684                modalEl.addEventListener('hidden.bs.modal', function onHidden() {
685                    modalEl.removeEventListener('hidden.bs.modal', onHidden);
686                    stopQrScanner();
687                });
688            }
689
690            function initializeScanner() {
691                html5QrCode = new Html5Qrcode("qr-reader");
692
693                var config = {
694                    fps: 10,
695                    qrbox: { width: 250, height: 250 },
696                    aspectRatio: 1.0
697                };
698
699                html5QrCode.start(
700                    { facingMode: "environment" },
701                    config,
702                    function(decodedText, decodedResult) {
703                        // Success - populate input and close modal
704                        document.getElementById('peer_info').value = decodedText;
705                        qrScannerModal.hide();
706                    },
707                    function(errorMessage) {
708                        // Ignore scan errors (happens constantly while searching)
709                    }
710                ).catch(function(err) {
711                    var errorEl = document.getElementById('qr-reader-error');
712                    errorEl.textContent = 'Unable to access camera: ' + err;
713                    errorEl.classList.remove('d-none');
714                });
715            }
716
717            function stopQrScanner() {
718                if (html5QrCode && html5QrCode.isScanning) {
719                    html5QrCode.stop().catch(function(err) {
720                        console.error('Error stopping scanner:', err);
721                    });
722                }
723            }
724            "#))
725        }
726    };
727
728    Html(
729        single_card_layout_with_version(
730            "Federation Setup",
731            content,
732            &version,
733            version_hash.as_deref(),
734        )
735        .into_string(),
736    )
737    .into_response()
738}
739
740// POST handler for adding peer connection info
741async fn post_add_setup_code(
742    State(state): State<UiState<DynSetupApi>>,
743    _auth: UserAuth,
744    Form(input): Form<PeerInfoInput>,
745) -> impl IntoResponse {
746    let error = state.api.add_peer_setup_code(input.peer_info).await.err();
747
748    let connected_peers = state.api.connected_peers().await;
749    let federation_size = state.api.federation_size().await;
750    let cfg_federation_name = state.api.cfg_federation_name().await;
751    let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
752    let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
753
754    Html(
755        peer_list_section(
756            &connected_peers,
757            federation_size,
758            &cfg_federation_name,
759            cfg_base_fees_disabled,
760            &cfg_enabled_modules,
761            error.as_ref().map(|e| e.to_string()).as_deref(),
762        )
763        .into_string(),
764    )
765    .into_response()
766}
767
768// POST handler for starting the DKG process
769async fn post_start_dkg(
770    State(state): State<UiState<DynSetupApi>>,
771    _auth: UserAuth,
772) -> impl IntoResponse {
773    let our_connection_info = state.api.setup_code().await;
774    let version = state.api.fedimintd_version().await;
775    let version_hash = state.api.fedimintd_version_hash().await;
776
777    match state.api.start_dkg().await {
778        Ok(()) => {
779            let content = html! {
780                @if let Some(ref info) = our_connection_info {
781                    p { "Share with guardians who still need it." }
782                    div class="mb-4" {
783                        (copiable_text(info))
784                    }
785                }
786
787                div class="alert alert-info mb-3" {
788                    "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
789                }
790
791                // Poll until the dashboard is ready, then redirect
792                div
793                    hx-get=(ROOT_ROUTE)
794                    hx-trigger="every 2s"
795                    hx-swap="none"
796                    hx-on--after-request={
797                        "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
798                    }
799                    style="display: none;"
800                {}
801
802                div class="text-center mt-4" {
803                    div class="spinner-border text-primary" role="status" {
804                        span class="visually-hidden" { "Loading..." }
805                    }
806                    p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
807                }
808            };
809
810            (
811                [("HX-Retarget", "body"), ("HX-Reswap", "innerHTML")],
812                Html(
813                    single_card_layout_with_version(
814                        "DKG Started",
815                        content,
816                        &version,
817                        version_hash.as_deref(),
818                    )
819                    .into_string(),
820                ),
821            )
822                .into_response()
823        }
824        Err(e) => {
825            let connected_peers = state.api.connected_peers().await;
826            let federation_size = state.api.federation_size().await;
827            let cfg_federation_name = state.api.cfg_federation_name().await;
828            let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
829            let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
830
831            Html(
832                peer_list_section(
833                    &connected_peers,
834                    federation_size,
835                    &cfg_federation_name,
836                    cfg_base_fees_disabled,
837                    &cfg_enabled_modules,
838                    Some(&e.to_string()),
839                )
840                .into_string(),
841            )
842            .into_response()
843        }
844    }
845}
846
847// POST handler for resetting peer connection info
848async fn post_reset_setup_codes(
849    State(state): State<UiState<DynSetupApi>>,
850    _auth: UserAuth,
851) -> impl IntoResponse {
852    state.api.reset_setup_codes().await;
853
854    Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
855}
856
857pub fn router(api: DynSetupApi) -> Router {
858    Router::new()
859        .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
860        .route(START_FEDERATION_ROUTE, get(start_federation_form))
861        .route(
862            RESTORE_GUARDIAN_ROUTE,
863            get(restore_form)
864                .post(restore_submit)
865                .layer(DefaultBodyLimit::max(RESTORE_BACKUP_UPLOAD_LIMIT_BYTES)),
866        )
867        .route(LOGIN_ROUTE, get(login_form_handler).post(login_submit))
868        .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
869        .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
870        .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
871        .route(START_DKG_ROUTE, post(post_start_dkg))
872        .route(
873            CONNECTIVITY_CHECK_ROUTE,
874            get(connectivity_check_handler::<DynSetupApi>),
875        )
876        .with_static_routes()
877        .with_state(UiState::new(api))
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    #[test]
885    fn setup_form_targets_error_container() {
886        let content = setup_form_content(&BTreeSet::new(), &BTreeSet::new()).into_string();
887
888        assert!(content.contains(r##"hx-target="#setup-error""##));
889        assert!(content.contains(r#"<div id="setup-error"></div>"#));
890    }
891
892    #[test]
893    fn setup_error_message_is_partial() {
894        let content = setup_error_message("Invalid federation size").into_string();
895
896        assert!(content.contains("Invalid federation size"));
897        assert!(!content.contains("setup-form"));
898    }
899
900    #[test]
901    fn setup_choice_has_start_and_restore_options() {
902        let content = setup_choice_content(None).into_string();
903
904        assert!(content.contains("Start new Federation"));
905        assert!(content.contains("Restore from backup"));
906        assert!(!content.contains("multipart/form-data"));
907    }
908
909    #[test]
910    fn restore_form_has_upload_fields() {
911        let content = restore_form_content(None).into_string();
912
913        assert!(content.contains("multipart/form-data"));
914        assert!(content.contains("Guardian Password"));
915        assert!(content.contains("Restore Guardian"));
916    }
917}