1use std::collections::BTreeSet;
2
3use axum::Router;
4use axum::extract::{DefaultBodyLimit, Multipart, State};
5use axum::http::StatusCode;
6use axum::response::{Html, IntoResponse, Redirect};
7use axum::routing::{get, post};
8use axum_extra::extract::Form;
9use axum_extra::extract::cookie::CookieJar;
10use fedimint_core::core::ModuleKind;
11use fedimint_core::module::ApiAuth;
12use fedimint_server_core::setup_ui::DynSetupApi;
13use fedimint_ui_common::assets::WithStaticRoutesExt;
14use fedimint_ui_common::auth::UserAuth;
15use fedimint_ui_common::{
16 CONNECTIVITY_CHECK_ROUTE, LOGIN_ROUTE, LoginInput, ROOT_ROUTE, UiState,
17 connectivity_check_handler, copiable_text, login_form, login_submit_response,
18 single_card_layout, single_card_layout_with_version,
19};
20use maud::{Markup, PreEscaped, html};
21use qrcode::QrCode;
22use serde::Deserialize;
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";
29pub const START_FEDERATION_ROUTE: &str = "/start_federation";
30pub const RESTORE_GUARDIAN_ROUTE: &str = "/restore_guardian";
31const RESTORE_BACKUP_UPLOAD_LIMIT_BYTES: usize = 10 * 1024 * 1024;
32
33#[derive(Debug, Deserialize)]
34pub(crate) struct SetupInput {
35 pub password: String,
36 pub name: String,
37 #[serde(default)]
38 pub is_lead: bool,
39 pub federation_name: String,
40 #[serde(default)]
41 pub federation_size: String,
42 #[serde(default)] pub enable_base_fees: bool,
44 #[serde(default)] pub enabled_modules: Vec<String>,
46}
47
48#[derive(Debug, Deserialize)]
49pub(crate) struct PeerInfoInput {
50 pub peer_info: String,
51}
52
53fn peer_list_section(
54 connected_peers: &[String],
55 federation_size: Option<u32>,
56 cfg_federation_name: &Option<String>,
57 cfg_base_fees_disabled: Option<bool>,
58 cfg_enabled_modules: &Option<BTreeSet<ModuleKind>>,
59 error: Option<&str>,
60) -> Markup {
61 let total_guardians = connected_peers.len() + 1;
62 let can_start_dkg = federation_size
63 .map(|expected| total_guardians == expected as usize)
64 .unwrap_or(false);
65
66 html! {
67 div id="peer-list-section" {
68 @if let Some(expected) = federation_size {
69 p { (format!("{total_guardians} of {expected} guardians connected.")) }
70 } @else {
71 p { "Add setup code for every other guardian." }
72 }
73
74 @if !connected_peers.is_empty() {
75 ul class="list-group mb-2" {
76 @for peer in connected_peers {
77 li class="list-group-item" { (peer) }
78 }
79 }
80
81 form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
82 div class="text-center mb-4" {
83 button type="button" class="btn btn-link text-danger text-decoration-none p-0" onclick="if(confirm('Are you sure you want to reset all guardians?')){document.getElementById('reset-form').submit();}" {
84 "Reset Guardians"
85 }
86 }
87 }
88
89 @if can_start_dkg {
90 @let has_settings = cfg_federation_name.is_some()
92 || federation_size.is_some()
93 || cfg_base_fees_disabled.is_some()
94 || cfg_enabled_modules.is_some();
95
96 form id="start-dkg-form" hx-post=(START_DKG_ROUTE) hx-target="#peer-list-section" hx-swap="outerHTML" {
97 @if let Some(error) = error {
98 div class="alert alert-danger mb-3" { (error) }
99 }
100 button type="submit" class="btn btn-warning w-100 py-2" { "Confirm" }
101 }
102
103 @if has_settings {
104 p class="text-muted mt-3 mb-0" style="font-size: 0.85rem;" {
105 @if let Some(name) = cfg_federation_name {
106 (name) " federation has been configured"
107 } @else {
108 "The federation has been configured"
109 }
110 @if let Some(disabled) = cfg_base_fees_disabled {
111 " with base fees "
112 @if disabled { "disabled" } @else { "enabled" }
113 }
114 @if let Some(modules) = cfg_enabled_modules {
115 " and modules "
116 (modules.iter().map(|m| m.as_str().to_owned()).collect::<Vec<_>>().join(", "))
117 }
118 "."
119 }
120 }
121 } @else {
122 form id="add-setup-code-form" hx-post=(ADD_SETUP_CODE_ROUTE) hx-target="#peer-list-section" hx-swap="outerHTML" {
124 div class="mb-3" {
125 div class="input-group" {
126 input type="text" class="form-control" id="peer_info" name="peer_info"
127 placeholder="Paste Setup Code" required;
128 button type="button" class="btn btn-outline-secondary" onclick="startQrScanner()" title="Scan QR Code" {
129 i class="bi bi-qr-code-scan" {}
130 }
131 }
132 }
133
134 @if let Some(error) = error {
135 div class="alert alert-danger mb-3" { (error) }
136 }
137 button type="submit" class="btn btn-primary w-100 py-2" { "Add Guardian" }
138 }
139 }
140 }
141 }
142}
143
144fn setup_error_message(error: &str) -> Markup {
145 html! {
146 div class="alert alert-danger mb-3" { (error) }
147 }
148}
149
150fn setup_choice_content(error: Option<&str>) -> Markup {
151 html! {
152 @if let Some(error) = error {
153 (setup_error_message(error))
154 }
155
156 div class="d-grid gap-3" {
157 a href=(START_FEDERATION_ROUTE) class="btn btn-primary w-100 py-2" {
158 "Start new Federation"
159 }
160
161 a href=(RESTORE_GUARDIAN_ROUTE) class="btn btn-outline-secondary w-100 py-2" {
162 "Restore from backup"
163 }
164 }
165 }
166}
167
168fn restore_form_content(error: Option<&str>) -> Markup {
169 html! {
170 @if let Some(error) = error {
171 (setup_error_message(error))
172 }
173
174 p class="text-muted" {
175 "Upload a guardian backup tar file and enter the guardian password used when the backup was created."
176 }
177
178 form method="post" action=(RESTORE_GUARDIAN_ROUTE) enctype="multipart/form-data" {
179 div class="form-group mb-3" {
180 input type="password" class="form-control" name="password" placeholder="Guardian Password" required;
181 }
182 div class="form-group mb-3" {
183 input type="file" class="form-control" name="backup" accept="application/x-tar,.tar" required;
184 }
185 button type="submit" class="btn btn-primary w-100 py-2" {
186 "Restore Guardian"
187 }
188 }
189
190 div class="text-center mt-3" {
191 a href=(ROOT_ROUTE) class="btn btn-link text-muted text-decoration-none" {
192 "Back"
193 }
194 }
195 }
196}
197
198fn restore_error_response(error: impl AsRef<str>) -> axum::response::Response {
199 (
200 StatusCode::BAD_REQUEST,
201 Html(
202 single_card_layout(
203 "Restore Guardian",
204 restore_form_content(Some(error.as_ref())),
205 )
206 .into_string(),
207 ),
208 )
209 .into_response()
210}
211
212fn setup_form_content(
213 available_modules: &BTreeSet<ModuleKind>,
214 default_modules: &BTreeSet<ModuleKind>,
215) -> Markup {
216 html! {
217 form id="setup-form" hx-post=(ROOT_ROUTE) hx-target="#setup-error" hx-swap="innerHTML" {
218 style {
219 r#"
220 .toggle-content {
221 display: none;
222 }
223
224 .toggle-control:checked ~ .toggle-content {
225 display: block;
226 }
227
228 #base-fees-warning {
229 display: block;
230 }
231
232 .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
233 display: none;
234 }
235
236 .accordion-button {
237 background-color: #f8f9fa;
238 }
239
240 .accordion-button:not(.collapsed) {
241 background-color: #f8f9fa;
242 box-shadow: none;
243 }
244
245 .accordion-button:focus {
246 box-shadow: none;
247 }
248
249 #modules-warning {
250 display: none;
251 }
252
253 #modules-list:has(.form-check-input:not(:checked)) ~ #modules-warning {
254 display: block;
255 }
256 "#
257 }
258
259 div class="form-group mb-4" {
260 input type="text" class="form-control" id="name" name="name" placeholder="Your Guardian Name" required;
261 }
262
263 div class="form-group mb-4" {
264 input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required;
265 }
266
267 div class="alert alert-warning mb-3" style="font-size: 0.875rem;" {
268 "Exactly one guardian must set the global config."
269 }
270
271 div class="form-group mb-4" {
272 input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
273
274 label class="form-check-label ms-2" for="is_lead" {
275 "Set the global config"
276 }
277
278 div class="toggle-content mt-3" {
279 input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation Name";
280
281 div class="form-group mt-3" {
282 label class="form-label" for="federation_size" {
283 "Total number of guardians (including you)"
284 }
285 select class="form-select" id="federation_size" name="federation_size" {
286 option value="" selected disabled { "Federation Size" }
287 option value="1" { "1 — Testing" }
288 option value="4" { "4 — Recommended" }
289 option value="5" { "5" }
290 option value="6" { "6" }
291 option value="7" { "7 — Recommended" }
292 option value="8" { "8" }
293 option value="9" { "9" }
294 option value="10" { "10 — Recommended" }
295 option value="11" { "11" }
296 option value="12" { "12" }
297 option value="13" { "13 — Recommended" }
298 option value="14" { "14" }
299 option value="15" { "15" }
300 option value="16" { "16 — Recommended" }
301 option value="17" { "17" }
302 option value="18" { "18" }
303 option value="19" { "19 — Recommended" }
304 option value="20" { "20" }
305 }
306 }
307
308 div class="form-check mt-3" {
309 input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
310
311 label class="form-check-label" for="enable_base_fees" {
312 "Enable base fees for this federation"
313 }
314 }
315
316 div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
317 strong { "Warning: " }
318 "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."
319 }
320
321 div class="accordion mt-3" id="modulesAccordion" {
322 div class="accordion-item" {
323 h2 class="accordion-header" {
324 button class="accordion-button collapsed" type="button"
325 data-bs-toggle="collapse" data-bs-target="#modulesConfig"
326 aria-expanded="false" aria-controls="modulesConfig" {
327 "Advanced: Configure Enabled Modules"
328 }
329 }
330 div id="modulesConfig" class="accordion-collapse collapse" data-bs-parent="#modulesAccordion" {
331 div class="accordion-body" {
332 div id="modules-list" {
333 @for kind in available_modules {
334 div class="form-check" {
335 input type="checkbox" class="form-check-input"
336 id=(format!("module_{}", kind.as_str()))
337 name="enabled_modules"
338 value=(kind.as_str())
339 checked[default_modules.contains(kind)];
340
341 label class="form-check-label" for=(format!("module_{}", kind.as_str())) {
342 (kind.as_str())
343 @if !default_modules.contains(kind) {
344 span class="badge bg-warning text-dark ms-2" { "experimental" }
345 }
346 }
347 }
348 }
349 }
350
351 div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
352 "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
353 }
354 }
355 }
356 }
357 }
358 }
359 }
360
361 div id="setup-error" {}
362 button type="submit" class="btn btn-primary w-100 py-2" { "Confirm" }
363 }
364 }
365}
366
367async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
369 if state.api.setup_code().await.is_some() {
370 return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
371 }
372
373 Html(single_card_layout("Guardian Setup", setup_choice_content(None)).into_string())
374 .into_response()
375}
376
377async fn start_federation_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
379 if state.api.setup_code().await.is_some() {
380 return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
381 }
382
383 let available_modules = state.api.available_modules();
384 let default_modules = state.api.default_modules();
385 let content = setup_form_content(&available_modules, &default_modules);
386 let version = state.api.fedimintd_version().await;
387 let version_hash = state.api.fedimintd_version_hash().await;
388
389 Html(
390 single_card_layout_with_version(
391 "Guardian Setup",
392 content,
393 &version,
394 version_hash.as_deref(),
395 )
396 .into_string(),
397 )
398 .into_response()
399}
400
401async fn setup_submit(
403 State(state): State<UiState<DynSetupApi>>,
404 Form(input): Form<SetupInput>,
405) -> impl IntoResponse {
406 let federation_name = if input.is_lead {
408 Some(input.federation_name)
409 } else {
410 None
411 };
412
413 let disable_base_fees = if input.is_lead {
414 Some(!input.enable_base_fees)
415 } else {
416 None
417 };
418
419 let enabled_modules = if input.is_lead {
420 let enabled: BTreeSet<ModuleKind> = input
421 .enabled_modules
422 .into_iter()
423 .map(|s| ModuleKind::clone_from_str(&s))
424 .collect();
425
426 Some(enabled)
427 } else {
428 None
429 };
430
431 let federation_size = if input.is_lead {
432 let s = input.federation_size.trim();
433 if s.is_empty() {
434 None
435 } else {
436 match s.parse::<u32>() {
437 Ok(size) => Some(size),
438 Err(_) => {
439 return Html(setup_error_message("Invalid federation size").into_string())
440 .into_response();
441 }
442 }
443 }
444 } else {
445 None
446 };
447
448 match state
449 .api
450 .set_local_parameters(
451 ApiAuth::new(input.password),
452 input.name,
453 federation_name,
454 disable_base_fees,
455 enabled_modules,
456 federation_size,
457 )
458 .await
459 {
460 Ok(_) => (
461 [("HX-Redirect", FEDERATION_SETUP_ROUTE)],
462 Html(String::new()),
463 )
464 .into_response(),
465 Err(e) => Html(setup_error_message(&e.to_string()).into_string()).into_response(),
466 }
467}
468
469async fn restore_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
471 if state.api.setup_code().await.is_some() {
472 return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
473 }
474
475 Html(single_card_layout("Restore Guardian", restore_form_content(None)).into_string())
476 .into_response()
477}
478
479async fn restore_submit(
480 State(state): State<UiState<DynSetupApi>>,
481 mut multipart: Multipart,
482) -> impl IntoResponse {
483 let mut password = None;
484 let mut backup = None;
485
486 loop {
487 let field = match multipart.next_field().await {
488 Ok(Some(field)) => field,
489 Ok(None) => break,
490 Err(e) => return restore_error_response(format!("Failed to read upload: {e}")),
491 };
492
493 match field.name() {
494 Some("password") => match field.text().await {
495 Ok(value) => password = Some(value),
496 Err(e) => return restore_error_response(format!("Failed to read password: {e}")),
497 },
498 Some("backup") => match field.bytes().await {
499 Ok(value) => backup = Some(value.to_vec()),
503 Err(e) => return restore_error_response(format!("Failed to read backup: {e}")),
504 },
505 _ => {}
506 }
507 }
508
509 let Some(password) = password else {
510 return restore_error_response("Missing guardian password");
511 };
512 let Some(backup) = backup else {
513 return restore_error_response("Missing guardian backup file");
514 };
515
516 match state.api.restore_from_backup(password, backup).await {
517 Ok(()) => {
518 let content = html! {
519 div class="alert alert-success mb-3" {
520 "Guardian backup restored. The server is starting consensus."
521 }
522 div class="text-center mt-4" {
523 div class="spinner-border text-primary" role="status" {
524 span class="visually-hidden" { "Loading..." }
525 }
526 p class="mt-2 text-muted" { "Waiting for dashboard..." }
527 }
528 div
529 hx-get=(ROOT_ROUTE)
530 hx-trigger="every 2s"
531 hx-swap="none"
532 hx-on--after-request={
533 "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
534 }
535 style="display: none;"
536 {}
537 };
538 Html(single_card_layout("Guardian Restored", content).into_string()).into_response()
539 }
540 Err(e) => restore_error_response(e.to_string()),
541 }
542}
543
544async fn login_form_handler(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
546 if state.api.setup_code().await.is_none() {
547 return Redirect::to(ROOT_ROUTE).into_response();
548 }
549
550 let version = state.api.fedimintd_version().await;
551 let version_hash = state.api.fedimintd_version_hash().await;
552 Html(
553 single_card_layout_with_version(
554 "Enter Password",
555 login_form(None),
556 &version,
557 version_hash.as_deref(),
558 )
559 .into_string(),
560 )
561 .into_response()
562}
563
564async fn login_submit(
566 State(state): State<UiState<DynSetupApi>>,
567 jar: CookieJar,
568 Form(input): Form<LoginInput>,
569) -> impl IntoResponse {
570 let auth = match state.api.auth().await {
571 Some(auth) => auth,
572 None => return Redirect::to(ROOT_ROUTE).into_response(),
573 };
574
575 login_submit_response(
576 auth,
577 state.auth_cookie_name,
578 state.auth_cookie_value,
579 jar,
580 input,
581 )
582 .into_response()
583}
584
585async fn federation_setup(
587 State(state): State<UiState<DynSetupApi>>,
588 _auth: UserAuth,
589) -> impl IntoResponse {
590 let our_connection_info = state
591 .api
592 .setup_code()
593 .await
594 .expect("Successful authentication ensures that the local parameters have been set");
595
596 let version = state.api.fedimintd_version().await;
597 let version_hash = state.api.fedimintd_version_hash().await;
598 let connected_peers = state.api.connected_peers().await;
599 let federation_size = state.api.federation_size().await;
600 let cfg_federation_name = state.api.cfg_federation_name().await;
601 let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
602 let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
603
604 let content = html! {
605 p { "Share this with your fellow guardians." }
606
607 @let qr_svg = QrCode::new(&our_connection_info)
608 .expect("Failed to generate QR code")
609 .render::<qrcode::render::svg::Color>()
610 .build();
611
612 div class="text-center mb-3" {
613 div class="border rounded p-2 bg-white d-inline-block" style="width: 250px; max-width: 100%;" {
614 div style="width: 100%; height: auto; overflow: hidden;" {
615 (PreEscaped(format!(r#"<div style="width: 100%; height: auto;">{}</div>"#,
616 qr_svg.replace("width=", "data-width=")
617 .replace("height=", "data-height=")
618 .replace("<svg", r#"<svg style="width: 100%; height: auto; display: block;""#))))
619 }
620 }
621 }
622
623 div class="mb-4" {
624 (copiable_text(&our_connection_info))
625 }
626
627 (peer_list_section(&connected_peers, federation_size, &cfg_federation_name, cfg_base_fees_disabled, &cfg_enabled_modules, None))
628
629 div class="modal fade" id="qrScannerModal" tabindex="-1" aria-labelledby="qrScannerModalLabel" aria-hidden="true" {
631 div class="modal-dialog modal-dialog-centered" {
632 div class="modal-content" {
633 div class="modal-header" {
634 h5 class="modal-title" id="qrScannerModalLabel" { "Scan Setup Code" }
635 button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
636 }
637 div class="modal-body" {
638 div id="qr-reader" style="width: 100%;" {}
639 div id="qr-reader-error" class="alert alert-danger mt-3 d-none" {}
640 }
641 div class="modal-footer" {
642 button type="button" class="btn btn-secondary" data-bs-dismiss="modal" { "Cancel" }
643 }
644 }
645 }
646 }
647
648 script src="/assets/html5-qrcode.min.js" {}
649
650 script {
652 (PreEscaped(r#"
653 var html5QrCode = null;
654 var qrScannerModal = null;
655
656 function startQrScanner() {
657 // Check for Flutter override hook
658 if (typeof window.fedimintQrScannerOverride === 'function') {
659 window.fedimintQrScannerOverride(function(result) {
660 if (result) {
661 document.getElementById('peer_info').value = result;
662 }
663 });
664 return;
665 }
666
667 var modalEl = document.getElementById('qrScannerModal');
668 qrScannerModal = new bootstrap.Modal(modalEl);
669
670 // Reset error message
671 var errorEl = document.getElementById('qr-reader-error');
672 errorEl.classList.add('d-none');
673 errorEl.textContent = '';
674
675 qrScannerModal.show();
676
677 // Wait for modal to be shown before starting camera
678 modalEl.addEventListener('shown.bs.modal', function onShown() {
679 modalEl.removeEventListener('shown.bs.modal', onShown);
680 initializeScanner();
681 });
682
683 // Clean up when modal is hidden
684 modalEl.addEventListener('hidden.bs.modal', function onHidden() {
685 modalEl.removeEventListener('hidden.bs.modal', onHidden);
686 stopQrScanner();
687 });
688 }
689
690 function initializeScanner() {
691 html5QrCode = new Html5Qrcode("qr-reader");
692
693 var config = {
694 fps: 10,
695 qrbox: { width: 250, height: 250 },
696 aspectRatio: 1.0
697 };
698
699 html5QrCode.start(
700 { facingMode: "environment" },
701 config,
702 function(decodedText, decodedResult) {
703 // Success - populate input and close modal
704 document.getElementById('peer_info').value = decodedText;
705 qrScannerModal.hide();
706 },
707 function(errorMessage) {
708 // Ignore scan errors (happens constantly while searching)
709 }
710 ).catch(function(err) {
711 var errorEl = document.getElementById('qr-reader-error');
712 errorEl.textContent = 'Unable to access camera: ' + err;
713 errorEl.classList.remove('d-none');
714 });
715 }
716
717 function stopQrScanner() {
718 if (html5QrCode && html5QrCode.isScanning) {
719 html5QrCode.stop().catch(function(err) {
720 console.error('Error stopping scanner:', err);
721 });
722 }
723 }
724 "#))
725 }
726 };
727
728 Html(
729 single_card_layout_with_version(
730 "Federation Setup",
731 content,
732 &version,
733 version_hash.as_deref(),
734 )
735 .into_string(),
736 )
737 .into_response()
738}
739
740async fn post_add_setup_code(
742 State(state): State<UiState<DynSetupApi>>,
743 _auth: UserAuth,
744 Form(input): Form<PeerInfoInput>,
745) -> impl IntoResponse {
746 let error = state.api.add_peer_setup_code(input.peer_info).await.err();
747
748 let connected_peers = state.api.connected_peers().await;
749 let federation_size = state.api.federation_size().await;
750 let cfg_federation_name = state.api.cfg_federation_name().await;
751 let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
752 let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
753
754 Html(
755 peer_list_section(
756 &connected_peers,
757 federation_size,
758 &cfg_federation_name,
759 cfg_base_fees_disabled,
760 &cfg_enabled_modules,
761 error.as_ref().map(|e| e.to_string()).as_deref(),
762 )
763 .into_string(),
764 )
765 .into_response()
766}
767
768async fn post_start_dkg(
770 State(state): State<UiState<DynSetupApi>>,
771 _auth: UserAuth,
772) -> impl IntoResponse {
773 let our_connection_info = state.api.setup_code().await;
774 let version = state.api.fedimintd_version().await;
775 let version_hash = state.api.fedimintd_version_hash().await;
776
777 match state.api.start_dkg().await {
778 Ok(()) => {
779 let content = html! {
780 @if let Some(ref info) = our_connection_info {
781 p { "Share with guardians who still need it." }
782 div class="mb-4" {
783 (copiable_text(info))
784 }
785 }
786
787 div class="alert alert-info mb-3" {
788 "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
789 }
790
791 div
793 hx-get=(ROOT_ROUTE)
794 hx-trigger="every 2s"
795 hx-swap="none"
796 hx-on--after-request={
797 "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
798 }
799 style="display: none;"
800 {}
801
802 div class="text-center mt-4" {
803 div class="spinner-border text-primary" role="status" {
804 span class="visually-hidden" { "Loading..." }
805 }
806 p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
807 }
808 };
809
810 (
811 [("HX-Retarget", "body"), ("HX-Reswap", "innerHTML")],
812 Html(
813 single_card_layout_with_version(
814 "DKG Started",
815 content,
816 &version,
817 version_hash.as_deref(),
818 )
819 .into_string(),
820 ),
821 )
822 .into_response()
823 }
824 Err(e) => {
825 let connected_peers = state.api.connected_peers().await;
826 let federation_size = state.api.federation_size().await;
827 let cfg_federation_name = state.api.cfg_federation_name().await;
828 let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
829 let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
830
831 Html(
832 peer_list_section(
833 &connected_peers,
834 federation_size,
835 &cfg_federation_name,
836 cfg_base_fees_disabled,
837 &cfg_enabled_modules,
838 Some(&e.to_string()),
839 )
840 .into_string(),
841 )
842 .into_response()
843 }
844 }
845}
846
847async fn post_reset_setup_codes(
849 State(state): State<UiState<DynSetupApi>>,
850 _auth: UserAuth,
851) -> impl IntoResponse {
852 state.api.reset_setup_codes().await;
853
854 Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
855}
856
857pub fn router(api: DynSetupApi) -> Router {
858 Router::new()
859 .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
860 .route(START_FEDERATION_ROUTE, get(start_federation_form))
861 .route(
862 RESTORE_GUARDIAN_ROUTE,
863 get(restore_form)
864 .post(restore_submit)
865 .layer(DefaultBodyLimit::max(RESTORE_BACKUP_UPLOAD_LIMIT_BYTES)),
866 )
867 .route(LOGIN_ROUTE, get(login_form_handler).post(login_submit))
868 .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
869 .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
870 .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
871 .route(START_DKG_ROUTE, post(post_start_dkg))
872 .route(
873 CONNECTIVITY_CHECK_ROUTE,
874 get(connectivity_check_handler::<DynSetupApi>),
875 )
876 .with_static_routes()
877 .with_state(UiState::new(api))
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883
884 #[test]
885 fn setup_form_targets_error_container() {
886 let content = setup_form_content(&BTreeSet::new(), &BTreeSet::new()).into_string();
887
888 assert!(content.contains(r##"hx-target="#setup-error""##));
889 assert!(content.contains(r#"<div id="setup-error"></div>"#));
890 }
891
892 #[test]
893 fn setup_error_message_is_partial() {
894 let content = setup_error_message("Invalid federation size").into_string();
895
896 assert!(content.contains("Invalid federation size"));
897 assert!(!content.contains("setup-form"));
898 }
899
900 #[test]
901 fn setup_choice_has_start_and_restore_options() {
902 let content = setup_choice_content(None).into_string();
903
904 assert!(content.contains("Start new Federation"));
905 assert!(content.contains("Restore from backup"));
906 assert!(!content.contains("multipart/form-data"));
907 }
908
909 #[test]
910 fn restore_form_has_upload_fields() {
911 let content = restore_form_content(None).into_string();
912
913 assert!(content.contains("multipart/form-data"));
914 assert!(content.contains("Guardian Password"));
915 assert!(content.contains("Restore Guardian"));
916 }
917}