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