fedimint_server_ui/
setup.rs

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