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