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