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