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