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