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,
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() -> impl IntoResponse {
39    Html(single_card_layout("Enter Password", login_form(None)).into_string())
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}
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.as_str(), &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.verify(&input.current_password) {
109        let content = html! {
110            div class="alert alert-danger" { "Current password is incorrect" }
111            a href="/" class="btn btn-primary w-100 py-2" { "Return to Dashboard" }
112        };
113        return Html(single_card_layout("Password Change Failed", content).into_string())
114            .into_response();
115    }
116
117    // Verify new password confirmation
118    if input.new_password != input.confirm_password {
119        let content = html! {
120            div class="alert alert-danger" { "New passwords do not match" }
121            a href="/" class="btn btn-primary w-100 py-2" { "Return to Dashboard" }
122        };
123        return Html(single_card_layout("Password Change Failed", content).into_string())
124            .into_response();
125    }
126
127    // Check that new password is not empty
128    if input.new_password.is_empty() {
129        let content = html! {
130            div class="alert alert-danger" { "New password cannot be empty" }
131            a href="/" class="btn btn-primary w-100 py-2" { "Return to Dashboard" }
132        };
133        return Html(single_card_layout("Password Change Failed", content).into_string())
134            .into_response();
135    }
136
137    // Call the API to change the password
138    match state
139        .api
140        .change_password(
141            &input.new_password,
142            &input.current_password,
143            &user_auth.guardian_auth_token,
144        )
145        .await
146    {
147        Ok(()) => {
148            let content = html! {
149                div class="alert alert-success" {
150                    "Password changed successfully! The server will restart now."
151                }
152                div class="alert alert-info" {
153                    "You will need to log in again with your new password once the server restarts."
154                }
155                a href="/login" class="btn btn-primary w-100 py-2" { "Go to Login" }
156            };
157            Html(single_card_layout("Password Changed", content).into_string()).into_response()
158        }
159        Err(err) => {
160            let content = html! {
161                div class="alert alert-danger" {
162                    "Failed to change password: " (err)
163                }
164                a href="/" class="btn btn-primary w-100 py-2" { "Return to Dashboard" }
165            };
166            Html(single_card_layout("Password Change Failed", content).into_string())
167                .into_response()
168        }
169    }
170}
171
172// Main dashboard view
173async fn dashboard_view(
174    State(state): State<UiState<DynDashboardApi>>,
175    _auth: UserAuth,
176) -> impl IntoResponse {
177    let guardian_names = state.api.guardian_names().await;
178    let federation_name = state.api.federation_name().await;
179    let session_count = state.api.session_count().await;
180    let fedimintd_version = state.api.fedimintd_version().await;
181    let consensus_ord_latency = state.api.consensus_ord_latency().await;
182    let p2p_connection_status = state.api.p2p_connection_status().await;
183    let invite_code = state.api.federation_invite_code().await;
184    let audit_summary = state.api.federation_audit().await;
185    let bitcoin_rpc_url = state.api.bitcoin_rpc_url().await;
186    let bitcoin_rpc_status = state.api.bitcoin_rpc_status().await;
187
188    let content = html! {
189        div class="row gy-4" {
190            div class="col-md-6" {
191                (general::render(&federation_name, session_count, &guardian_names))
192            }
193
194            div class="col-md-6" {
195                (invite::render(&invite_code, session_count))
196            }
197        }
198
199        div class="row gy-4 mt-2" {
200            div class="col-lg-6" {
201                (audit::render(&audit_summary))
202            }
203
204            div class="col-lg-6" {
205                (latency::render(consensus_ord_latency, &p2p_connection_status))
206            }
207        }
208
209        div class="row gy-4 mt-2" {
210            div class="col-12" {
211                (bitcoin::render(bitcoin_rpc_url, &bitcoin_rpc_status))
212            }
213        }
214
215        // Conditionally add Lightning V2 UI if the module is available
216        @if let Some(lightning) = state.api.get_module::<fedimint_lnv2_server::Lightning>() {
217            div class="row gy-4 mt-2" {
218                div class="col-12" {
219                    (lnv2::render(lightning).await)
220                }
221            }
222        }
223
224        // Conditionally add Wallet V2 UI if the module is available
225        @if let Some(walletv2_module) = state.api.get_module::<fedimint_walletv2_server::Wallet>() {
226            (walletv2::render(walletv2_module).await)
227        }
228
229        // Conditionally add Mint V2 UI if the module is available
230        @if let Some(mint_module) = state.api.get_module::<fedimint_mintv2_server::Mint>() {
231            div class="row gy-4 mt-2" {
232                div class="col-12" {
233                    (mintv2::render(mint_module).await)
234                }
235            }
236        }
237
238        // Conditionally add Wallet UI if the module is available
239        @if let Some(wallet_module) = state.api.get_module::<fedimint_wallet_server::Wallet>() {
240            div class="row gy-4 mt-2" {
241                div class="col-12" {
242                    (wallet::render(wallet_module).await)
243                }
244            }
245        }
246
247        // Conditionally add Meta UI if the module is available
248        @if let Some(meta_module) = state.api.get_module::<fedimint_meta_server::Meta>() {
249            div class="row gy-4 mt-2" {
250                div class="col-12" {
251                    (meta::render(meta_module).await)
252                }
253            }
254        }
255
256        // Guardian Backup and Password Change side by side
257        div class="row gy-4 mt-2" {
258            div class="col-lg-6" {
259                div class="card h-100" {
260                    div class="card-header dashboard-header" { "Guardian Backup" }
261                    div class="card-body" {
262                        div class="alert alert-warning mb-3" {
263                            "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."
264                        }
265                        a href="/download-backup" class="btn btn-primary" {
266                            "Download"
267                        }
268                    }
269                }
270            }
271
272            div class="col-lg-6" {
273                div class="card h-100" {
274                    div class="card-header dashboard-header" { "Change Password" }
275                    div class="card-body" {
276                        div class="alert alert-warning mb-3" {
277                            "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."
278                        }
279                        form method="post" action="/change-password" {
280                            div class="form-group mb-3" {
281                                label for="current_password" class="form-label" { "Current Password" }
282                                input type="password" class="form-control" id="current_password" name="current_password" placeholder="Enter current password" required;
283                            }
284                            div class="form-group mb-3" {
285                                label for="new_password" class="form-label" { "New Password" }
286                                input type="password" class="form-control" id="new_password" name="new_password" placeholder="Enter new password" required;
287                            }
288                            div class="form-group mb-3" {
289                                label for="confirm_password" class="form-label" { "Confirm New Password" }
290                                input type="password" class="form-control" id="confirm_password" name="confirm_password" placeholder="Confirm new password" required;
291                            }
292                            button type="submit" class="btn btn-primary" {
293                                "Change Password"
294                            }
295                        }
296                    }
297                }
298            }
299        }
300    };
301
302    Html(dashboard_layout(content, &fedimintd_version).into_string()).into_response()
303}
304
305pub fn router(api: DynDashboardApi) -> Router {
306    let mut app = Router::new()
307        .route(ROOT_ROUTE, get(dashboard_view))
308        .route(LOGIN_ROUTE, get(login_form_handler).post(login_submit))
309        .route(EXPLORER_ROUTE, get(consensus_explorer_view))
310        .route(EXPLORER_IDX_ROUTE, get(consensus_explorer_view))
311        .route(DOWNLOAD_BACKUP_ROUTE, get(download_backup))
312        .route(CHANGE_PASSWORD_ROUTE, post(change_password))
313        .route(METRICS_ROUTE, get(metrics_handler))
314        .route(
315            CONNECTIVITY_CHECK_ROUTE,
316            get(connectivity_check_handler::<DynDashboardApi>),
317        )
318        .with_static_routes();
319
320    // routeradd LNv2 gateway routes if the module exists
321    if api
322        .get_module::<fedimint_lnv2_server::Lightning>()
323        .is_some()
324    {
325        app = app
326            .route(lnv2::LNV2_ADD_ROUTE, post(lnv2::post_add))
327            .route(lnv2::LNV2_REMOVE_ROUTE, post(lnv2::post_remove));
328    }
329
330    // Only add Meta module routes if the module exists
331    if api.get_module::<fedimint_meta_server::Meta>().is_some() {
332        app = app
333            .route(meta::META_SUBMIT_ROUTE, post(meta::post_submit))
334            .route(meta::META_SET_ROUTE, post(meta::post_set))
335            .route(meta::META_RESET_ROUTE, post(meta::post_reset))
336            .route(meta::META_DELETE_ROUTE, post(meta::post_delete))
337            .route(meta::META_MERGE_ROUTE, post(meta::post_merge))
338            .route(meta::META_VALUE_INPUT_ROUTE, get(meta::get_value_input));
339    }
340
341    // Finalize the router with state
342    app.with_state(UiState::new(api))
343}