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, html};
16use serde::Deserialize;
17
18use crate::{common_head, login_submit_response};
19
20// Setup route constants
21pub const FEDERATION_SETUP_ROUTE: &str = "/federation_setup";
22pub const ADD_SETUP_CODE_ROUTE: &str = "/add_setup_code";
23pub const RESET_SETUP_CODES_ROUTE: &str = "/reset_setup_codes";
24pub const START_DKG_ROUTE: &str = "/start_dkg";
25
26#[derive(Debug, Deserialize)]
27pub(crate) struct SetupInput {
28    pub password: String,
29    pub name: String,
30    #[serde(default)]
31    pub is_lead: bool,
32    pub federation_name: String,
33    #[serde(default)] // will not be sent if disabled
34    pub enable_base_fees: bool,
35    #[serde(default)] // list of enabled module kinds
36    pub enabled_modules: Vec<String>,
37}
38
39#[derive(Debug, Deserialize)]
40pub(crate) struct PeerInfoInput {
41    pub peer_info: String,
42}
43
44pub fn setup_layout(title: &str, content: Markup) -> Markup {
45    html! {
46        (DOCTYPE)
47        html {
48            head {
49                (common_head(title))
50            }
51            body {
52                div class="container" {
53                    div class="row justify-content-center" {
54                        div class="col-md-8 col-lg-5 narrow-container" {
55                            header class="text-center" {
56                                h1 class="header-title" { "Fedimint Guardian UI" }
57                            }
58
59                            div class="card" {
60                                div class="card-body" {
61                                    (content)
62                                }
63                            }
64                        }
65                    }
66                }
67                script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
68            }
69        }
70    }
71}
72
73// GET handler for the /setup route (display the setup form)
74async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
75    if state.api.setup_code().await.is_some() {
76        return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
77    }
78
79    let available_modules = state.api.available_modules();
80
81    let content = html! {
82        form method="post" action=(ROOT_ROUTE) {
83            style {
84                r#"
85                .toggle-content {
86                    display: none;
87                }
88
89                .toggle-control:checked ~ .toggle-content {
90                    display: block;
91                }
92
93                #base-fees-warning {
94                    display: block;
95                }
96
97                .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
98                    display: none;
99                }
100
101                .accordion-button {
102                    background-color: #f8f9fa;
103                }
104
105                .accordion-button:not(.collapsed) {
106                    background-color: #f8f9fa;
107                    box-shadow: none;
108                }
109
110                .accordion-button:focus {
111                    box-shadow: none;
112                }
113
114                #modules-warning {
115                    display: none;
116                }
117
118                #modules-list:has(.form-check-input:not(:checked)) ~ #modules-warning {
119                    display: block;
120                }
121                "#
122            }
123
124            div class="form-group mb-4" {
125                input type="text" class="form-control" id="name" name="name" placeholder="Your Guardian Name" required;
126            }
127
128            div class="form-group mb-4" {
129                input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required;
130            }
131
132            div class="alert alert-warning mb-3" style="font-size: 0.875rem;" {
133                "Exactly one guardian must set the global config."
134            }
135
136            div class="form-group mb-4" {
137                input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
138
139                label class="form-check-label ms-2" for="is_lead" {
140                    "Set the global config"
141                }
142
143                div class="toggle-content mt-3" {
144                    input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation Name";
145
146                    div class="form-check mt-3" {
147                        input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
148
149                        label class="form-check-label" for="enable_base_fees" {
150                            "Enable base fees for this federation"
151                        }
152                    }
153
154                    div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
155                        strong { "Warning: " }
156                        "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."
157                    }
158
159                    div class="accordion mt-3" id="modulesAccordion" {
160                        div class="accordion-item" {
161                            h2 class="accordion-header" {
162                                button class="accordion-button collapsed" type="button"
163                                    data-bs-toggle="collapse" data-bs-target="#modulesConfig"
164                                    aria-expanded="false" aria-controls="modulesConfig" {
165                                    "Advanced: Configure Enabled Modules"
166                                }
167                            }
168                            div id="modulesConfig" class="accordion-collapse collapse" data-bs-parent="#modulesAccordion" {
169                                div class="accordion-body" {
170                                    div id="modules-list" {
171                                        @for kind in &available_modules {
172                                            div class="form-check" {
173                                                input type="checkbox" class="form-check-input"
174                                                    id=(format!("module_{}", kind.as_str()))
175                                                    name="enabled_modules"
176                                                    value=(kind.as_str())
177                                                    checked;
178
179                                                label class="form-check-label" for=(format!("module_{}", kind.as_str())) {
180                                                    (kind.as_str())
181                                                }
182                                            }
183                                        }
184                                    }
185
186                                    div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
187                                        "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
188                                    }
189                                }
190                            }
191                        }
192                    }
193                }
194            }
195
196            div class="button-container" {
197                button type="submit" class="btn btn-primary setup-btn" { "Confirm" }
198            }
199        }
200    };
201
202    Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
203}
204
205// POST handler for the /setup route (process the password setup form)
206async fn setup_submit(
207    State(state): State<UiState<DynSetupApi>>,
208    Form(input): Form<SetupInput>,
209) -> impl IntoResponse {
210    // Only use these settings if is_lead is true
211    let federation_name = if input.is_lead {
212        Some(input.federation_name)
213    } else {
214        None
215    };
216
217    let disable_base_fees = if input.is_lead {
218        Some(!input.enable_base_fees)
219    } else {
220        None
221    };
222
223    let enabled_modules = if input.is_lead {
224        let enabled: BTreeSet<ModuleKind> = input
225            .enabled_modules
226            .into_iter()
227            .map(|s| ModuleKind::clone_from_str(&s))
228            .collect();
229
230        Some(enabled)
231    } else {
232        None
233    };
234
235    match state
236        .api
237        .set_local_parameters(
238            ApiAuth(input.password),
239            input.name,
240            federation_name,
241            disable_base_fees,
242            enabled_modules,
243        )
244        .await
245    {
246        Ok(_) => Redirect::to(LOGIN_ROUTE).into_response(),
247        Err(e) => {
248            let content = html! {
249                div class="alert alert-danger" { (e.to_string()) }
250                div class="button-container" {
251                    a href=(ROOT_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
252                }
253            };
254
255            Html(setup_layout("Setup Error", content).into_string()).into_response()
256        }
257    }
258}
259
260// GET handler for the /login route (display the login form)
261async fn login_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
262    if state.api.setup_code().await.is_none() {
263        return Redirect::to(ROOT_ROUTE).into_response();
264    }
265
266    login_form_response("Fedimint Guardian Login").into_response()
267}
268
269// POST handler for the /login route (authenticate and set session cookie)
270async fn login_submit(
271    State(state): State<UiState<DynSetupApi>>,
272    jar: CookieJar,
273    Form(input): Form<LoginInput>,
274) -> impl IntoResponse {
275    let auth = match state.api.auth().await {
276        Some(auth) => auth,
277        None => return Redirect::to(ROOT_ROUTE).into_response(),
278    };
279
280    login_submit_response(
281        auth,
282        state.auth_cookie_name,
283        state.auth_cookie_value,
284        jar,
285        input,
286    )
287    .into_response()
288}
289
290// GET handler for the /federation-setup route (main federation management page)
291async fn federation_setup(
292    State(state): State<UiState<DynSetupApi>>,
293    _auth: UserAuth,
294) -> impl IntoResponse {
295    let our_connection_info = state
296        .api
297        .setup_code()
298        .await
299        .expect("Successful authentication ensures that the local parameters have been set");
300
301    let connected_peers = state.api.connected_peers().await;
302
303    let content = html! {
304        section class="mb-4" {
305            h4 { "Your setup code" }
306
307            p { "Share it with other guardians." }
308            div class="alert alert-info mb-3" {
309                (our_connection_info)
310            }
311
312            div class="text-center" {
313                button type="button" class="btn btn-outline-primary setup-btn"
314                    onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
315                    "Copy to Clipboard"
316                }
317            }
318        }
319
320        hr class="my-4" {}
321
322        section class="mb-4" {
323            h4 { "Other guardians" }
324
325            p { "Add setup code of every other guardian." }
326
327            ul class="list-group mb-4" {
328                @for peer in connected_peers {
329                    li class="list-group-item" { (peer) }
330                }
331            }
332
333            form method="post" action=(ADD_SETUP_CODE_ROUTE) {
334                div class="mb-3" {
335                    input type="text" class="form-control mb-2" id="peer_info" name="peer_info"
336                        placeholder="Paste setup code" required;
337                }
338
339                div class="row mt-3" {
340                    div class="col-6" {
341                        button type="button" class="btn btn-warning w-100" onclick="document.getElementById('reset-form').submit();" {
342                            "Reset Guardians"
343                        }
344                    }
345
346                    div class="col-6" {
347                        button type="submit" class="btn btn-primary w-100" { "Add Guardian" }
348                    }
349                }
350            }
351
352            form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
353        }
354
355        hr class="my-4" {}
356
357        section class="mb-4" {
358            div class="alert alert-warning mb-4" {
359                "Verify " b { "all" } " other guardians were added. This process cannot be reversed once started."
360            }
361
362            div class="text-center" {
363                form method="post" action=(START_DKG_ROUTE) {
364                    button type="submit" class="btn btn-warning setup-btn" {
365                        "🚀 Confirm"
366                    }
367                }
368            }
369        }
370    };
371
372    Html(setup_layout("Federation Setup", content).into_string()).into_response()
373}
374
375// POST handler for adding peer connection info
376async fn post_add_setup_code(
377    State(state): State<UiState<DynSetupApi>>,
378    _auth: UserAuth,
379    Form(input): Form<PeerInfoInput>,
380) -> impl IntoResponse {
381    match state.api.add_peer_setup_code(input.peer_info).await {
382        Ok(..) => Redirect::to(FEDERATION_SETUP_ROUTE).into_response(),
383        Err(e) => {
384            let content = html! {
385                div class="alert alert-danger" { (e.to_string()) }
386                div class="button-container" {
387                    a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
388                }
389            };
390
391            Html(setup_layout("Error", content).into_string()).into_response()
392        }
393    }
394}
395
396// POST handler for starting the DKG process
397async fn post_start_dkg(
398    State(state): State<UiState<DynSetupApi>>,
399    _auth: UserAuth,
400) -> impl IntoResponse {
401    match state.api.start_dkg().await {
402        Ok(()) => {
403            // Show DKG progress page with htmx polling
404            let content = html! {
405                div class="alert alert-success my-4" {
406                    "Setting up Federation..."
407                }
408
409                p class="text-center" {
410                    "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
411                }
412
413                // Hidden div that will poll and redirect when the normal UI is ready
414                div
415                    hx-get=(ROOT_ROUTE)
416                    hx-trigger="every 2s"
417                    hx-swap="none"
418                    hx-on--after-request={
419                        "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
420                    }
421                    style="display: none;"
422                {}
423
424                div class="text-center mt-4" {
425                    div class="spinner-border text-primary" role="status" {
426                        span class="visually-hidden" { "Loading..." }
427                    }
428                    p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
429                }
430            };
431
432            Html(setup_layout("DKG Started", content).into_string()).into_response()
433        }
434        Err(e) => {
435            let content = html! {
436                div class="alert alert-danger" { (e.to_string()) }
437                div class="button-container" {
438                    a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
439                }
440            };
441
442            Html(setup_layout("Error", content).into_string()).into_response()
443        }
444    }
445}
446
447// POST handler for resetting peer connection info
448async fn post_reset_setup_codes(
449    State(state): State<UiState<DynSetupApi>>,
450    _auth: UserAuth,
451) -> impl IntoResponse {
452    state.api.reset_setup_codes().await;
453
454    Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
455}
456
457pub fn router(api: DynSetupApi) -> Router {
458    Router::new()
459        .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
460        .route(LOGIN_ROUTE, get(login_form).post(login_submit))
461        .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
462        .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
463        .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
464        .route(START_DKG_ROUTE, post(post_start_dkg))
465        .with_static_routes()
466        .with_state(UiState::new(api))
467}