1use std::collections::BTreeSet;
2
3use axum::Router;
4use axum::extract::State;
5use axum::response::{Html, IntoResponse, Redirect};
6use axum::routing::{get, post};
7use axum_extra::extract::Form;
8use axum_extra::extract::cookie::CookieJar;
9use fedimint_core::core::ModuleKind;
10use fedimint_core::module::ApiAuth;
11use fedimint_server_core::setup_ui::DynSetupApi;
12use fedimint_ui_common::assets::WithStaticRoutesExt;
13use fedimint_ui_common::auth::UserAuth;
14use fedimint_ui_common::{
15 CONNECTIVITY_CHECK_ROUTE, LOGIN_ROUTE, LoginInput, ROOT_ROUTE, UiState,
16 connectivity_check_handler, connectivity_widget, login_form_response,
17};
18use maud::{DOCTYPE, Markup, PreEscaped, html};
19use qrcode::QrCode;
20use serde::Deserialize;
21
22use crate::{common_head, login_submit_response};
23
24pub const FEDERATION_SETUP_ROUTE: &str = "/federation_setup";
26pub const ADD_SETUP_CODE_ROUTE: &str = "/add_setup_code";
27pub const RESET_SETUP_CODES_ROUTE: &str = "/reset_setup_codes";
28pub const START_DKG_ROUTE: &str = "/start_dkg";
29
30#[derive(Debug, Deserialize)]
31pub(crate) struct SetupInput {
32 pub password: String,
33 pub name: String,
34 #[serde(default)]
35 pub is_lead: bool,
36 pub federation_name: String,
37 #[serde(default)]
38 pub federation_size: String,
39 #[serde(default)] pub enable_base_fees: bool,
41 #[serde(default)] pub enabled_modules: Vec<String>,
43}
44
45#[derive(Debug, Deserialize)]
46pub(crate) struct PeerInfoInput {
47 pub peer_info: String,
48}
49
50pub fn setup_layout(title: &str, content: Markup) -> Markup {
51 html! {
52 (DOCTYPE)
53 html {
54 head {
55 (common_head(title))
56 }
57 body {
58 div class="container" {
59 div class="row justify-content-center" {
60 div class="col-md-8 col-lg-5 narrow-container" {
61 header class="text-center" {
62 h1 class="header-title" { "Fedimint Guardian UI" }
63 }
64
65 div class="card" {
66 div class="card-body" {
67 (content)
68 }
69 }
70 }
71 }
72 }
73 (connectivity_widget())
74 script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
75 script src="/assets/html5-qrcode.min.js" {}
76 }
77 }
78 }
79}
80
81async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
83 if state.api.setup_code().await.is_some() {
84 return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
85 }
86
87 let available_modules = state.api.available_modules();
88 let default_modules = state.api.default_modules();
89
90 let content = html! {
91 form method="post" action=(ROOT_ROUTE)
92 hx-post=(ROOT_ROUTE) hx-target="#setup-error" hx-swap="innerHTML" {
93 style {
94 r#"
95 .toggle-content {
96 display: none;
97 }
98
99 .toggle-control:checked ~ .toggle-content {
100 display: block;
101 }
102
103 #base-fees-warning {
104 display: block;
105 }
106
107 .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
108 display: none;
109 }
110
111 .accordion-button {
112 background-color: #f8f9fa;
113 }
114
115 .accordion-button:not(.collapsed) {
116 background-color: #f8f9fa;
117 box-shadow: none;
118 }
119
120 .accordion-button:focus {
121 box-shadow: none;
122 }
123
124 #modules-warning {
125 display: none;
126 }
127
128 #modules-list:has(.form-check-input:not(:checked)) ~ #modules-warning {
129 display: block;
130 }
131 "#
132 }
133
134 div class="form-group mb-4" {
135 input type="text" class="form-control" id="name" name="name" placeholder="Your Guardian Name" required;
136 }
137
138 div class="form-group mb-4" {
139 input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required;
140 }
141
142 div class="alert alert-warning mb-3" style="font-size: 0.875rem;" {
143 "Exactly one guardian must set the global config."
144 }
145
146 div class="form-group mb-4" {
147 input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
148
149 label class="form-check-label ms-2" for="is_lead" {
150 "Set the global config"
151 }
152
153 div class="toggle-content mt-3" {
154 input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation Name";
155
156 div class="form-group mt-3" {
157 label class="form-label" for="federation_size" {
158 "Total number of guardians (including you)"
159 }
160 input type="number" class="form-control" id="federation_size"
161 name="federation_size" min="1";
162 small class="form-text text-muted" {
163 "At least 4, or 1. Recommended: 4, 7, 10, 13."
164 }
165 }
166
167 div class="form-check mt-3" {
168 input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
169
170 label class="form-check-label" for="enable_base_fees" {
171 "Enable base fees for this federation"
172 }
173 }
174
175 div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
176 strong { "Warning: " }
177 "Base fees discourage spam and wasting storage space. The typical fee is only 1-3 sats per transaction, regardless of the value transferred. We recommend enabling the base fee and it cannot be changed later."
178 }
179
180 div class="accordion mt-3" id="modulesAccordion" {
181 div class="accordion-item" {
182 h2 class="accordion-header" {
183 button class="accordion-button collapsed" type="button"
184 data-bs-toggle="collapse" data-bs-target="#modulesConfig"
185 aria-expanded="false" aria-controls="modulesConfig" {
186 "Advanced: Configure Enabled Modules"
187 }
188 }
189 div id="modulesConfig" class="accordion-collapse collapse" data-bs-parent="#modulesAccordion" {
190 div class="accordion-body" {
191 div id="modules-list" {
192 @for kind in &available_modules {
193 div class="form-check" {
194 input type="checkbox" class="form-check-input"
195 id=(format!("module_{}", kind.as_str()))
196 name="enabled_modules"
197 value=(kind.as_str())
198 checked[default_modules.contains(kind)];
199
200 label class="form-check-label" for=(format!("module_{}", kind.as_str())) {
201 (kind.as_str())
202 }
203 }
204 }
205 }
206
207 div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
208 "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
209 }
210 }
211 }
212 }
213 }
214 }
215 }
216
217 div id="setup-error" {}
218
219 div class="button-container" {
220 button type="submit" class="btn btn-primary setup-btn" { "Confirm" }
221 }
222 }
223 };
224
225 Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
226}
227
228async fn setup_submit(
230 State(state): State<UiState<DynSetupApi>>,
231 Form(input): Form<SetupInput>,
232) -> impl IntoResponse {
233 let federation_name = if input.is_lead {
235 Some(input.federation_name)
236 } else {
237 None
238 };
239
240 let disable_base_fees = if input.is_lead {
241 Some(!input.enable_base_fees)
242 } else {
243 None
244 };
245
246 let enabled_modules = if input.is_lead {
247 let enabled: BTreeSet<ModuleKind> = input
248 .enabled_modules
249 .into_iter()
250 .map(|s| ModuleKind::clone_from_str(&s))
251 .collect();
252
253 Some(enabled)
254 } else {
255 None
256 };
257
258 let federation_size = if input.is_lead {
259 let s = input.federation_size.trim();
260 if s.is_empty() {
261 None
262 } else {
263 match s.parse::<u32>() {
264 Ok(size) => Some(size),
265 Err(_) => {
266 return Html(
267 html! {
268 div class="alert alert-danger" { "Invalid federation size" }
269 }
270 .into_string(),
271 )
272 .into_response();
273 }
274 }
275 }
276 } else {
277 None
278 };
279
280 match state
281 .api
282 .set_local_parameters(
283 ApiAuth(input.password),
284 input.name,
285 federation_name,
286 disable_base_fees,
287 enabled_modules,
288 federation_size,
289 )
290 .await
291 {
292 Ok(_) => ([("HX-Redirect", LOGIN_ROUTE)], Html(String::new())).into_response(),
293 Err(e) => Html(
294 html! {
295 div class="alert alert-danger" { (e.to_string()) }
296 }
297 .into_string(),
298 )
299 .into_response(),
300 }
301}
302
303async fn login_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
305 if state.api.setup_code().await.is_none() {
306 return Redirect::to(ROOT_ROUTE).into_response();
307 }
308
309 login_form_response("Fedimint Guardian Login").into_response()
310}
311
312async fn login_submit(
314 State(state): State<UiState<DynSetupApi>>,
315 jar: CookieJar,
316 Form(input): Form<LoginInput>,
317) -> impl IntoResponse {
318 let auth = match state.api.auth().await {
319 Some(auth) => auth,
320 None => return Redirect::to(ROOT_ROUTE).into_response(),
321 };
322
323 login_submit_response(
324 auth,
325 state.auth_cookie_name,
326 state.auth_cookie_value,
327 jar,
328 input,
329 )
330 .into_response()
331}
332
333async fn federation_setup(
335 State(state): State<UiState<DynSetupApi>>,
336 _auth: UserAuth,
337) -> impl IntoResponse {
338 let our_connection_info = state
339 .api
340 .setup_code()
341 .await
342 .expect("Successful authentication ensures that the local parameters have been set");
343
344 let connected_peers = state.api.connected_peers().await;
345 let guardian_name = state.api.guardian_name().await;
346 let federation_size = state.api.federation_size().await;
347 let cfg_federation_name = state.api.cfg_federation_name().await;
348 let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
349 let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
350 let total_guardians = connected_peers.len() + 1;
351 let can_start_dkg = federation_size
352 .map(|expected| total_guardians == expected as usize)
353 .unwrap_or(false);
354
355 let content = html! {
356 @if let Some(ref name) = guardian_name {
357 section class="mb-4" {
358 h4 { "Your name" }
359 p { (name) }
360 }
361 }
362
363 section class="mb-4" {
364 h4 { "Federation settings" }
365 @if cfg_federation_name.is_some() || federation_size.is_some() || cfg_base_fees_disabled.is_some() || cfg_enabled_modules.is_some() {
366 ul class="list-group list-group-flush" {
367 @if let Some(ref name) = cfg_federation_name {
368 li class="list-group-item" {
369 strong { "Federation name: " }
370 (name)
371 }
372 }
373 @if let Some(size) = federation_size {
374 li class="list-group-item" {
375 strong { "Federation size: " }
376 (size)
377 }
378 }
379 @if let Some(disabled) = cfg_base_fees_disabled {
380 li class="list-group-item" {
381 strong { "Base fees: " }
382 @if disabled { "disabled" } @else { "enabled" }
383 }
384 }
385 @if let Some(ref modules) = cfg_enabled_modules {
386 li class="list-group-item" {
387 strong { "Enabled modules: " }
388 (modules.iter().map(|m| m.as_str().to_owned()).collect::<Vec<_>>().join(", "))
389 }
390 }
391 }
392 } @else {
393 p class="text-muted" { "Leader's setup code not provided yet." }
394 }
395 }
396
397 hr class="my-4" {}
398
399 section class="mb-4" {
400 h4 { "Your setup code" }
401
402 p { "Share it with other guardians." }
403
404 @let qr_svg = QrCode::new(&our_connection_info)
405 .expect("Failed to generate QR code")
406 .render::<qrcode::render::svg::Color>()
407 .build();
408
409 div class="text-center mb-3" {
410 div class="border rounded p-2 bg-white d-inline-block" style="width: 250px; max-width: 100%;" {
411 div style="width: 100%; height: auto; overflow: hidden;" {
412 (PreEscaped(format!(r#"<div style="width: 100%; height: auto;">{}</div>"#,
413 qr_svg.replace("width=", "data-width=")
414 .replace("height=", "data-height=")
415 .replace("<svg", r#"<svg style="width: 100%; height: auto; display: block;""#))))
416 }
417 }
418 }
419
420 div class="alert alert-info mb-3" {
421 (our_connection_info)
422 }
423
424 div class="text-center" {
425 button type="button" class="btn btn-outline-primary setup-btn"
426 onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
427 "Copy to Clipboard"
428 }
429 }
430 }
431
432 hr class="my-4" {}
433
434 section class="mb-4" {
435 h4 { "Other guardians" }
436
437 @if let Some(expected) = federation_size {
438 p { (format!("{total_guardians} of {expected} guardians connected.")) }
439 } @else {
440 p { "Add setup code of every other guardian." }
441 }
442
443 ul class="list-group mb-4" {
444 @for peer in connected_peers {
445 li class="list-group-item" { (peer) }
446 }
447 }
448
449 form method="post" action=(ADD_SETUP_CODE_ROUTE) {
450 div class="mb-3" {
451 div class="input-group" {
452 input type="text" class="form-control" id="peer_info" name="peer_info"
453 placeholder="Paste setup code" required;
454 button type="button" class="btn btn-outline-secondary" onclick="startQrScanner()" title="Scan QR Code" {
455 (PreEscaped(r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M15 12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.172a3 3 0 0 0 2.12-.879l.83-.828A1 1 0 0 1 6.827 3h2.344a1 1 0 0 1 .707.293l.828.828A3 3 0 0 0 12.828 5H14a1 1 0 0 1 1 1zM2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4z"/><path d="M8 11a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5m0 1a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7M3 6.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/></svg>"#))
456 }
457 }
458 }
459
460 div class="row mt-3" {
461 div class="col-6" {
462 button type="button" class="btn btn-warning w-100" onclick="if(confirm('Are you sure you want to reset all guardians?')){document.getElementById('reset-form').submit();}" {
463 "Reset Guardians"
464 }
465 }
466
467 div class="col-6" {
468 button type="submit" class="btn btn-primary w-100"
469 disabled[can_start_dkg] {
470 @if can_start_dkg { "List complete" } @else { "Add Guardian" }
471 }
472 }
473 }
474 }
475
476 form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
477 }
478
479 hr class="my-4" {}
480
481 section class="mb-4" {
482 div class="alert alert-warning mb-4" {
483 "Verify " b { "all" } " other guardians were added. This process cannot be reversed once started."
484 }
485
486 div class="text-center" {
487 form method="post" action=(START_DKG_ROUTE) {
488 button type="submit" class="btn btn-warning setup-btn"
489 disabled[!can_start_dkg] {
490 "🚀 Confirm"
491 }
492 }
493 @if !can_start_dkg {
494 @if let Some(expected) = federation_size {
495 p class="text-muted mt-2" style="font-size: 0.875rem;" {
496 (format!("Need to collect {} more setup code(s).", expected as usize - total_guardians))
497 }
498 } @else {
499 p class="text-muted mt-2" style="font-size: 0.875rem;" {
500 "Need to collect the setup codes from all other guardians."
501 }
502 }
503 }
504 }
505 }
506
507 div class="modal fade" id="qrScannerModal" tabindex="-1" aria-labelledby="qrScannerModalLabel" aria-hidden="true" {
509 div class="modal-dialog modal-dialog-centered" {
510 div class="modal-content" {
511 div class="modal-header" {
512 h5 class="modal-title" id="qrScannerModalLabel" { "Scan Setup Code" }
513 button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
514 }
515 div class="modal-body" {
516 div id="qr-reader" style="width: 100%;" {}
517 div id="qr-reader-error" class="alert alert-danger mt-3 d-none" {}
518 }
519 div class="modal-footer" {
520 button type="button" class="btn btn-secondary" data-bs-dismiss="modal" { "Cancel" }
521 }
522 }
523 }
524 }
525
526 script {
528 (PreEscaped(r#"
529 var html5QrCode = null;
530 var qrScannerModal = null;
531
532 function startQrScanner() {
533 // Check for Flutter override hook
534 if (typeof window.fedimintQrScannerOverride === 'function') {
535 window.fedimintQrScannerOverride(function(result) {
536 if (result) {
537 document.getElementById('peer_info').value = result;
538 }
539 });
540 return;
541 }
542
543 var modalEl = document.getElementById('qrScannerModal');
544 qrScannerModal = new bootstrap.Modal(modalEl);
545
546 // Reset error message
547 var errorEl = document.getElementById('qr-reader-error');
548 errorEl.classList.add('d-none');
549 errorEl.textContent = '';
550
551 qrScannerModal.show();
552
553 // Wait for modal to be shown before starting camera
554 modalEl.addEventListener('shown.bs.modal', function onShown() {
555 modalEl.removeEventListener('shown.bs.modal', onShown);
556 initializeScanner();
557 });
558
559 // Clean up when modal is hidden
560 modalEl.addEventListener('hidden.bs.modal', function onHidden() {
561 modalEl.removeEventListener('hidden.bs.modal', onHidden);
562 stopQrScanner();
563 });
564 }
565
566 function initializeScanner() {
567 html5QrCode = new Html5Qrcode("qr-reader");
568
569 var config = {
570 fps: 10,
571 qrbox: { width: 250, height: 250 },
572 aspectRatio: 1.0
573 };
574
575 html5QrCode.start(
576 { facingMode: "environment" },
577 config,
578 function(decodedText, decodedResult) {
579 // Success - populate input and close modal
580 document.getElementById('peer_info').value = decodedText;
581 qrScannerModal.hide();
582 },
583 function(errorMessage) {
584 // Ignore scan errors (happens constantly while searching)
585 }
586 ).catch(function(err) {
587 var errorEl = document.getElementById('qr-reader-error');
588 errorEl.textContent = 'Unable to access camera: ' + err;
589 errorEl.classList.remove('d-none');
590 });
591 }
592
593 function stopQrScanner() {
594 if (html5QrCode && html5QrCode.isScanning) {
595 html5QrCode.stop().catch(function(err) {
596 console.error('Error stopping scanner:', err);
597 });
598 }
599 }
600 "#))
601 }
602 };
603
604 Html(setup_layout("Federation Setup", content).into_string()).into_response()
605}
606
607async fn post_add_setup_code(
609 State(state): State<UiState<DynSetupApi>>,
610 _auth: UserAuth,
611 Form(input): Form<PeerInfoInput>,
612) -> impl IntoResponse {
613 match state.api.add_peer_setup_code(input.peer_info).await {
614 Ok(..) => Redirect::to(FEDERATION_SETUP_ROUTE).into_response(),
615 Err(e) => {
616 let content = html! {
617 div class="alert alert-danger" { (e.to_string()) }
618 div class="button-container" {
619 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
620 }
621 };
622
623 Html(setup_layout("Error", content).into_string()).into_response()
624 }
625 }
626}
627
628async fn post_start_dkg(
630 State(state): State<UiState<DynSetupApi>>,
631 _auth: UserAuth,
632) -> impl IntoResponse {
633 let our_connection_info = state.api.setup_code().await;
634
635 match state.api.start_dkg().await {
636 Ok(()) => {
637 let content = html! {
639 div class="alert alert-success my-4" {
640 "Setting up Federation..."
641 }
642
643 p class="text-center" {
644 "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
645 }
646
647 @if let Some(ref info) = our_connection_info {
648 hr class="my-4" {}
649 section class="mb-4" {
650 h4 { "Your setup code" }
651 p { "Share with guardians who still need it." }
652 div class="alert alert-info mb-3" {
653 (info)
654 }
655 div class="text-center" {
656 button type="button" class="btn btn-outline-primary setup-btn"
657 onclick=(format!("navigator.clipboard.writeText('{info}')")) {
658 "Copy to Clipboard"
659 }
660 }
661 }
662 }
663
664 div
666 hx-get=(ROOT_ROUTE)
667 hx-trigger="every 2s"
668 hx-swap="none"
669 hx-on--after-request={
670 "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
671 }
672 style="display: none;"
673 {}
674
675 div class="text-center mt-4" {
676 div class="spinner-border text-primary" role="status" {
677 span class="visually-hidden" { "Loading..." }
678 }
679 p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
680 }
681 };
682
683 Html(setup_layout("DKG Started", content).into_string()).into_response()
684 }
685 Err(e) => {
686 let content = html! {
687 div class="alert alert-danger" { (e.to_string()) }
688 div class="button-container" {
689 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
690 }
691 };
692
693 Html(setup_layout("Error", content).into_string()).into_response()
694 }
695 }
696}
697
698async fn post_reset_setup_codes(
700 State(state): State<UiState<DynSetupApi>>,
701 _auth: UserAuth,
702) -> impl IntoResponse {
703 state.api.reset_setup_codes().await;
704
705 Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
706}
707
708pub fn router(api: DynSetupApi) -> Router {
709 Router::new()
710 .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
711 .route(LOGIN_ROUTE, get(login_form).post(login_submit))
712 .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
713 .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
714 .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
715 .route(START_DKG_ROUTE, post(post_start_dkg))
716 .route(
717 CONNECTIVITY_CHECK_ROUTE,
718 get(connectivity_check_handler::<DynSetupApi>),
719 )
720 .with_static_routes()
721 .with_state(UiState::new(api))
722}