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