Skip to main content

fedimint_server_ui/dashboard/
mod.rs

1pub mod audit;
2pub mod bitcoin;
3pub(crate) mod consensus_explorer;
4pub mod general;
5pub mod invite;
6pub mod latency;
7pub mod modules;
8
9use axum::Router;
10use axum::body::Body;
11use axum::extract::{Form, State};
12use axum::http::{StatusCode, header};
13use axum::response::{Html, IntoResponse, Response};
14use axum::routing::{get, post};
15use axum_extra::extract::cookie::CookieJar;
16use consensus_explorer::consensus_explorer_view;
17use fedimint_metrics::{Encoder, REGISTRY, TextEncoder};
18use fedimint_server_core::dashboard_ui::{DashboardApiModuleExt, DynDashboardApi};
19use fedimint_ui_common::assets::WithStaticRoutesExt;
20use fedimint_ui_common::auth::UserAuth;
21use fedimint_ui_common::{
22    CONNECTIVITY_CHECK_ROUTE, LOGIN_ROUTE, ROOT_ROUTE, UiState, connectivity_check_handler,
23    dashboard_layout, login_form_response,
24};
25use maud::html;
26use {
27    fedimint_lnv2_server, fedimint_meta_server, fedimint_mintv2_server, fedimint_wallet_server,
28    fedimint_walletv2_server,
29};
30
31use crate::dashboard::modules::{lnv2, meta, mintv2, wallet, walletv2};
32use crate::{
33    CHANGE_PASSWORD_ROUTE, DOWNLOAD_BACKUP_ROUTE, EXPLORER_IDX_ROUTE, EXPLORER_ROUTE, LoginInput,
34    METRICS_ROUTE, login_submit_response,
35};
36
37// Dashboard login form handler
38async fn login_form(State(_state): State<UiState<DynDashboardApi>>) -> impl IntoResponse {
39    login_form_response("Fedimint Guardian Login")
40}
41
42// Dashboard login submit handler
43async fn login_submit(
44    State(state): State<UiState<DynDashboardApi>>,
45    jar: CookieJar,
46    Form(input): Form<LoginInput>,
47) -> impl IntoResponse {
48    login_submit_response(
49        state.api.auth().await,
50        state.auth_cookie_name,
51        state.auth_cookie_value,
52        jar,
53        input,
54    )
55    .into_response()
56}
57
58// Download backup handler
59async fn download_backup(
60    State(state): State<UiState<DynDashboardApi>>,
61    user_auth: UserAuth,
62) -> impl IntoResponse {
63    let api_auth = state.api.auth().await;
64    let backup = state
65        .api
66        .download_guardian_config_backup(api_auth.as_str(), &user_auth.guardian_auth_token)
67        .await;
68    let filename = "guardian-backup.tar";
69
70    Response::builder()
71        .header(header::CONTENT_TYPE, "application/x-tar")
72        .header(
73            header::CONTENT_DISPOSITION,
74            format!("attachment; filename=\"{filename}\""),
75        )
76        .body(Body::from(backup.tar_archive_bytes))
77        .expect("Failed to build response")
78}
79
80// Prometheus metrics handler
81async fn metrics_handler(_user_auth: UserAuth) -> impl IntoResponse {
82    let metric_families = REGISTRY.gather();
83    let result = || -> Result<String, Box<dyn std::error::Error>> {
84        let mut buffer = Vec::new();
85        let encoder = TextEncoder::new();
86        encoder.encode(&metric_families, &mut buffer)?;
87        Ok(String::from_utf8(buffer)?)
88    };
89    match result() {
90        Ok(metrics) => (
91            StatusCode::OK,
92            [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
93            metrics,
94        )
95            .into_response(),
96        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")).into_response(),
97    }
98}
99
100// Password change handler
101async fn change_password(
102    State(state): State<UiState<DynDashboardApi>>,
103    user_auth: UserAuth,
104    Form(input): Form<crate::PasswordChangeInput>,
105) -> impl IntoResponse {
106    let api_auth = state.api.auth().await;
107
108    // Verify current password
109    if !api_auth.verify(&input.current_password) {
110        let content = html! {
111            div class="alert alert-danger" { "Current password is incorrect" }
112            div class="button-container" {
113                a href="/" class="btn btn-primary" { "Return to Dashboard" }
114            }
115        };
116        return Html(dashboard_layout(content, "Password Change Failed", None).into_string())
117            .into_response();
118    }
119
120    // Verify new password confirmation
121    if input.new_password != input.confirm_password {
122        let content = html! {
123            div class="alert alert-danger" { "New passwords do not match" }
124            div class="button-container" {
125                a href="/" class="btn btn-primary" { "Return to Dashboard" }
126            }
127        };
128        return Html(dashboard_layout(content, "Password Change Failed", None).into_string())
129            .into_response();
130    }
131
132    // Check that new password is not empty
133    if input.new_password.is_empty() {
134        let content = html! {
135            div class="alert alert-danger" { "New password cannot be empty" }
136            div class="button-container" {
137                a href="/" class="btn btn-primary" { "Return to Dashboard" }
138            }
139        };
140        return Html(dashboard_layout(content, "Password Change Failed", None).into_string())
141            .into_response();
142    }
143
144    // Call the API to change the password
145    match state
146        .api
147        .change_password(
148            &input.new_password,
149            &input.current_password,
150            &user_auth.guardian_auth_token,
151        )
152        .await
153    {
154        Ok(()) => {
155            let content = html! {
156                div class="alert alert-success" {
157                    "Password changed successfully! The server will restart now."
158                }
159                div class="alert alert-info" {
160                    "You will need to log in again with your new password once the server restarts."
161                }
162                div class="button-container" {
163                    a href="/login" class="btn btn-primary" { "Go to Login" }
164                }
165            };
166            Html(dashboard_layout(content, "Password Changed", None).into_string()).into_response()
167        }
168        Err(err) => {
169            let content = html! {
170                div class="alert alert-danger" {
171                    "Failed to change password: " (err)
172                }
173                div class="button-container" {
174                    a href="/" class="btn btn-primary" { "Return to Dashboard" }
175                }
176            };
177            Html(dashboard_layout(content, "Password Change Failed", None).into_string())
178                .into_response()
179        }
180    }
181}
182
183// Main dashboard view
184async fn dashboard_view(
185    State(state): State<UiState<DynDashboardApi>>,
186    _auth: UserAuth,
187) -> impl IntoResponse {
188    let guardian_names = state.api.guardian_names().await;
189    let federation_name = state.api.federation_name().await;
190    let session_count = state.api.session_count().await;
191    let fedimintd_version = state.api.fedimintd_version().await;
192    let consensus_ord_latency = state.api.consensus_ord_latency().await;
193    let p2p_connection_status = state.api.p2p_connection_status().await;
194    let invite_code = state.api.federation_invite_code().await;
195    let audit_summary = state.api.federation_audit().await;
196    let bitcoin_rpc_url = state.api.bitcoin_rpc_url().await;
197    let bitcoin_rpc_status = state.api.bitcoin_rpc_status().await;
198
199    let content = html! {
200        div class="row gy-4" {
201            div class="col-md-6" {
202                (general::render(&federation_name, session_count, &guardian_names))
203            }
204
205            div class="col-md-6" {
206                (invite::render(&invite_code, session_count))
207            }
208        }
209
210        div class="row gy-4 mt-2" {
211            div class="col-lg-6" {
212                (audit::render(&audit_summary))
213            }
214
215            div class="col-lg-6" {
216                (latency::render(consensus_ord_latency, &p2p_connection_status))
217            }
218        }
219
220        div class="row gy-4 mt-2" {
221            div class="col-12" {
222                (bitcoin::render(bitcoin_rpc_url, &bitcoin_rpc_status))
223            }
224        }
225
226        // Conditionally add Lightning V2 UI if the module is available
227        @if let Some(lightning) = state.api.get_module::<fedimint_lnv2_server::Lightning>() {
228            div class="row gy-4 mt-2" {
229                div class="col-12" {
230                    (lnv2::render(lightning).await)
231                }
232            }
233        }
234
235        // Conditionally add Wallet V2 UI if the module is available
236        @if let Some(walletv2_module) = state.api.get_module::<fedimint_walletv2_server::Wallet>() {
237            (walletv2::render(walletv2_module).await)
238        }
239
240        // Conditionally add Mint V2 UI if the module is available
241        @if let Some(mint_module) = state.api.get_module::<fedimint_mintv2_server::Mint>() {
242            div class="row gy-4 mt-2" {
243                div class="col-12" {
244                    (mintv2::render(mint_module).await)
245                }
246            }
247        }
248
249        // Conditionally add Wallet UI if the module is available
250        @if let Some(wallet_module) = state.api.get_module::<fedimint_wallet_server::Wallet>() {
251            div class="row gy-4 mt-2" {
252                div class="col-12" {
253                    (wallet::render(wallet_module).await)
254                }
255            }
256        }
257
258        // Conditionally add Meta UI if the module is available
259        @if let Some(meta_module) = state.api.get_module::<fedimint_meta_server::Meta>() {
260            div class="row gy-4 mt-2" {
261                div class="col-12" {
262                    (meta::render(meta_module).await)
263                }
264            }
265        }
266
267        // Guardian Configuration Backup section
268        div class="row gy-4 mt-4" {
269            div class="col-12" {
270                div class="card" {
271                    div class="card-header bg-warning text-dark" {
272                        h5 class="mb-0" { "Guardian Configuration Backup" }
273                    }
274                    div class="card-body" {
275                        div class="row" {
276                            div class="col-lg-6 mb-3 mb-lg-0" {
277                                p {
278                                    "You only need to download this backup once."
279                                }
280                                p {
281                                    "Use it to restore your guardian if your server fails."
282                                }
283                                a href="/download-backup" class="btn btn-outline-warning btn-lg mt-2" {
284                                    "Download Guardian Backup"
285                                }
286                            }
287                            div class="col-lg-6" {
288                                div class="alert alert-warning mb-0" {
289                                    strong { "Security Warning" }
290                                    br;
291                                    "Store this file securely since anyone with it and your password can run your guardian node."
292                                }
293                            }
294                        }
295                    }
296                }
297            }
298        }
299
300        // Password Reset section
301        div class="row gy-4 mt-4" {
302            div class="col-12" {
303                div class="card" {
304                    div class="card-header bg-info text-white" {
305                        h5 class="mb-0" { "Change Guardian Password" }
306                    }
307                    div class="card-body" {
308                        form method="post" action="/change-password" {
309                            div class="row" {
310                                div class="col-lg-6 mb-3" {
311                                    div class="form-group mb-3" {
312                                        label for="current_password" class="form-label" { "Current Password" }
313                                        input type="password" class="form-control" id="current_password" name="current_password" placeholder="Enter current password" required;
314                                    }
315                                    div class="form-group mb-3" {
316                                        label for="new_password" class="form-label" { "New Password" }
317                                        input type="password" class="form-control" id="new_password" name="new_password" placeholder="Enter new password" required;
318                                    }
319                                    div class="form-group mb-3" {
320                                        label for="confirm_password" class="form-label" { "Confirm New Password" }
321                                        input type="password" class="form-control" id="confirm_password" name="confirm_password" placeholder="Confirm new password" required;
322                                    }
323                                    button type="submit" class="btn btn-info btn-lg mt-2" {
324                                        "Change Password"
325                                    }
326                                }
327                                div class="col-lg-6" {
328                                    div class="alert alert-info mb-0" {
329                                        strong { "Important" }
330                                        br;
331                                        "After changing your password, you will need to log in again with the new password."
332                                    }
333                                    div class="alert alert-info mt-3" {
334                                        strong { "Just so you know" }
335                                        br;
336                                        "Fedimint instance will shut down and might require a manual restart (depending how it's run)"
337                                    }
338                                }
339                            }
340                        }
341                    }
342                }
343            }
344        }
345    };
346
347    Html(dashboard_layout(content, "Fedimint Guardian UI", Some(&fedimintd_version)).into_string())
348        .into_response()
349}
350
351pub fn router(api: DynDashboardApi) -> Router {
352    let mut app = Router::new()
353        .route(ROOT_ROUTE, get(dashboard_view))
354        .route(LOGIN_ROUTE, get(login_form).post(login_submit))
355        .route(EXPLORER_ROUTE, get(consensus_explorer_view))
356        .route(EXPLORER_IDX_ROUTE, get(consensus_explorer_view))
357        .route(DOWNLOAD_BACKUP_ROUTE, get(download_backup))
358        .route(CHANGE_PASSWORD_ROUTE, post(change_password))
359        .route(METRICS_ROUTE, get(metrics_handler))
360        .route(
361            CONNECTIVITY_CHECK_ROUTE,
362            get(connectivity_check_handler::<DynDashboardApi>),
363        )
364        .with_static_routes();
365
366    // routeradd LNv2 gateway routes if the module exists
367    if api
368        .get_module::<fedimint_lnv2_server::Lightning>()
369        .is_some()
370    {
371        app = app
372            .route(lnv2::LNV2_ADD_ROUTE, post(lnv2::post_add))
373            .route(lnv2::LNV2_REMOVE_ROUTE, post(lnv2::post_remove));
374    }
375
376    // Only add Meta module routes if the module exists
377    if api.get_module::<fedimint_meta_server::Meta>().is_some() {
378        app = app
379            .route(meta::META_SUBMIT_ROUTE, post(meta::post_submit))
380            .route(meta::META_SET_ROUTE, post(meta::post_set))
381            .route(meta::META_RESET_ROUTE, post(meta::post_reset))
382            .route(meta::META_DELETE_ROUTE, post(meta::post_delete))
383            .route(meta::META_MERGE_ROUTE, post(meta::post_merge))
384            .route(meta::META_VALUE_INPUT_ROUTE, get(meta::get_value_input));
385    }
386
387    // Finalize the router with state
388    app.with_state(UiState::new(api))
389}