fedimint_server_ui/dashboard/
mod.rs1pub 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
30async fn login_form(State(_state): State<UiState<DynDashboardApi>>) -> impl IntoResponse {
32 login_form_response("Fedimint Guardian Login")
33}
34
35async 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
51async 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
73async 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 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 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 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 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
156async 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 @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 @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 @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 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 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 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 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 app.with_state(UiState::new(api))
341}