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