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