fedimint_server_ui/
setup.rs1use 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::{LOGIN_ROUTE, LoginInput, ROOT_ROUTE, UiState, login_form_response};
15use maud::{DOCTYPE, Markup, PreEscaped, html};
16use qrcode::QrCode;
17use serde::Deserialize;
18
19use crate::{common_head, login_submit_response};
20
21pub const FEDERATION_SETUP_ROUTE: &str = "/federation_setup";
23pub const ADD_SETUP_CODE_ROUTE: &str = "/add_setup_code";
24pub const RESET_SETUP_CODES_ROUTE: &str = "/reset_setup_codes";
25pub const START_DKG_ROUTE: &str = "/start_dkg";
26
27#[derive(Debug, Deserialize)]
28pub(crate) struct SetupInput {
29 pub password: String,
30 pub name: String,
31 #[serde(default)]
32 pub is_lead: bool,
33 pub federation_name: String,
34 #[serde(default)] pub enable_base_fees: bool,
36 #[serde(default)] pub enabled_modules: Vec<String>,
38}
39
40#[derive(Debug, Deserialize)]
41pub(crate) struct PeerInfoInput {
42 pub peer_info: String,
43}
44
45pub fn setup_layout(title: &str, content: Markup) -> Markup {
46 html! {
47 (DOCTYPE)
48 html {
49 head {
50 (common_head(title))
51 }
52 body {
53 div class="container" {
54 div class="row justify-content-center" {
55 div class="col-md-8 col-lg-5 narrow-container" {
56 header class="text-center" {
57 h1 class="header-title" { "Fedimint Guardian UI" }
58 }
59
60 div class="card" {
61 div class="card-body" {
62 (content)
63 }
64 }
65 }
66 }
67 }
68 script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
69 script src="/assets/html5-qrcode.min.js" {}
70 }
71 }
72 }
73}
74
75async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
77 if state.api.setup_code().await.is_some() {
78 return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
79 }
80
81 let available_modules = state.api.available_modules();
82
83 let content = html! {
84 form method="post" action=(ROOT_ROUTE) {
85 style {
86 r#"
87 .toggle-content {
88 display: none;
89 }
90
91 .toggle-control:checked ~ .toggle-content {
92 display: block;
93 }
94
95 #base-fees-warning {
96 display: block;
97 }
98
99 .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
100 display: none;
101 }
102
103 .accordion-button {
104 background-color: #f8f9fa;
105 }
106
107 .accordion-button:not(.collapsed) {
108 background-color: #f8f9fa;
109 box-shadow: none;
110 }
111
112 .accordion-button:focus {
113 box-shadow: none;
114 }
115
116 #modules-warning {
117 display: none;
118 }
119
120 #modules-list:has(.form-check-input:not(:checked)) ~ #modules-warning {
121 display: block;
122 }
123 "#
124 }
125
126 div class="form-group mb-4" {
127 input type="text" class="form-control" id="name" name="name" placeholder="Your Guardian Name" required;
128 }
129
130 div class="form-group mb-4" {
131 input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required;
132 }
133
134 div class="alert alert-warning mb-3" style="font-size: 0.875rem;" {
135 "Exactly one guardian must set the global config."
136 }
137
138 div class="form-group mb-4" {
139 input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
140
141 label class="form-check-label ms-2" for="is_lead" {
142 "Set the global config"
143 }
144
145 div class="toggle-content mt-3" {
146 input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation Name";
147
148 div class="form-check mt-3" {
149 input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
150
151 label class="form-check-label" for="enable_base_fees" {
152 "Enable base fees for this federation"
153 }
154 }
155
156 div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
157 strong { "Warning: " }
158 "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."
159 }
160
161 div class="accordion mt-3" id="modulesAccordion" {
162 div class="accordion-item" {
163 h2 class="accordion-header" {
164 button class="accordion-button collapsed" type="button"
165 data-bs-toggle="collapse" data-bs-target="#modulesConfig"
166 aria-expanded="false" aria-controls="modulesConfig" {
167 "Advanced: Configure Enabled Modules"
168 }
169 }
170 div id="modulesConfig" class="accordion-collapse collapse" data-bs-parent="#modulesAccordion" {
171 div class="accordion-body" {
172 div id="modules-list" {
173 @for kind in &available_modules {
174 div class="form-check" {
175 input type="checkbox" class="form-check-input"
176 id=(format!("module_{}", kind.as_str()))
177 name="enabled_modules"
178 value=(kind.as_str())
179 checked;
180
181 label class="form-check-label" for=(format!("module_{}", kind.as_str())) {
182 (kind.as_str())
183 }
184 }
185 }
186 }
187
188 div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
189 "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
190 }
191 }
192 }
193 }
194 }
195 }
196 }
197
198 div class="button-container" {
199 button type="submit" class="btn btn-primary setup-btn" { "Confirm" }
200 }
201 }
202 };
203
204 Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
205}
206
207async fn setup_submit(
209 State(state): State<UiState<DynSetupApi>>,
210 Form(input): Form<SetupInput>,
211) -> impl IntoResponse {
212 let federation_name = if input.is_lead {
214 Some(input.federation_name)
215 } else {
216 None
217 };
218
219 let disable_base_fees = if input.is_lead {
220 Some(!input.enable_base_fees)
221 } else {
222 None
223 };
224
225 let enabled_modules = if input.is_lead {
226 let enabled: BTreeSet<ModuleKind> = input
227 .enabled_modules
228 .into_iter()
229 .map(|s| ModuleKind::clone_from_str(&s))
230 .collect();
231
232 Some(enabled)
233 } else {
234 None
235 };
236
237 match state
238 .api
239 .set_local_parameters(
240 ApiAuth(input.password),
241 input.name,
242 federation_name,
243 disable_base_fees,
244 enabled_modules,
245 )
246 .await
247 {
248 Ok(_) => Redirect::to(LOGIN_ROUTE).into_response(),
249 Err(e) => {
250 let content = html! {
251 div class="alert alert-danger" { (e.to_string()) }
252 div class="button-container" {
253 a href=(ROOT_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
254 }
255 };
256
257 Html(setup_layout("Setup Error", content).into_string()).into_response()
258 }
259 }
260}
261
262async fn login_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
264 if state.api.setup_code().await.is_none() {
265 return Redirect::to(ROOT_ROUTE).into_response();
266 }
267
268 login_form_response("Fedimint Guardian Login").into_response()
269}
270
271async fn login_submit(
273 State(state): State<UiState<DynSetupApi>>,
274 jar: CookieJar,
275 Form(input): Form<LoginInput>,
276) -> impl IntoResponse {
277 let auth = match state.api.auth().await {
278 Some(auth) => auth,
279 None => return Redirect::to(ROOT_ROUTE).into_response(),
280 };
281
282 login_submit_response(
283 auth,
284 state.auth_cookie_name,
285 state.auth_cookie_value,
286 jar,
287 input,
288 )
289 .into_response()
290}
291
292async fn federation_setup(
294 State(state): State<UiState<DynSetupApi>>,
295 _auth: UserAuth,
296) -> impl IntoResponse {
297 let our_connection_info = state
298 .api
299 .setup_code()
300 .await
301 .expect("Successful authentication ensures that the local parameters have been set");
302
303 let connected_peers = state.api.connected_peers().await;
304
305 let content = html! {
306 section class="mb-4" {
307 h4 { "Your setup code" }
308
309 p { "Share it with other guardians." }
310
311 @let qr_svg = QrCode::new(&our_connection_info)
312 .expect("Failed to generate QR code")
313 .render::<qrcode::render::svg::Color>()
314 .build();
315
316 div class="text-center mb-3" {
317 div class="border rounded p-2 bg-white d-inline-block" style="width: 250px; max-width: 100%;" {
318 div style="width: 100%; height: auto; overflow: hidden;" {
319 (PreEscaped(format!(r#"<div style="width: 100%; height: auto;">{}</div>"#,
320 qr_svg.replace("width=", "data-width=")
321 .replace("height=", "data-height=")
322 .replace("<svg", r#"<svg style="width: 100%; height: auto; display: block;""#))))
323 }
324 }
325 }
326
327 div class="alert alert-info mb-3" {
328 (our_connection_info)
329 }
330
331 div class="text-center" {
332 button type="button" class="btn btn-outline-primary setup-btn"
333 onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
334 "Copy to Clipboard"
335 }
336 }
337 }
338
339 hr class="my-4" {}
340
341 section class="mb-4" {
342 h4 { "Other guardians" }
343
344 p { "Add setup code of every other guardian." }
345
346 ul class="list-group mb-4" {
347 @for peer in connected_peers {
348 li class="list-group-item" { (peer) }
349 }
350 }
351
352 form method="post" action=(ADD_SETUP_CODE_ROUTE) {
353 div class="mb-3" {
354 div class="input-group" {
355 input type="text" class="form-control" id="peer_info" name="peer_info"
356 placeholder="Paste setup code" required;
357 button type="button" class="btn btn-outline-secondary" onclick="startQrScanner()" title="Scan QR Code" {
358 (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>"#))
359 }
360 }
361 }
362
363 div class="row mt-3" {
364 div class="col-6" {
365 button type="button" class="btn btn-warning w-100" onclick="document.getElementById('reset-form').submit();" {
366 "Reset Guardians"
367 }
368 }
369
370 div class="col-6" {
371 button type="submit" class="btn btn-primary w-100" { "Add Guardian" }
372 }
373 }
374 }
375
376 form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
377 }
378
379 hr class="my-4" {}
380
381 section class="mb-4" {
382 div class="alert alert-warning mb-4" {
383 "Verify " b { "all" } " other guardians were added. This process cannot be reversed once started."
384 }
385
386 div class="text-center" {
387 form method="post" action=(START_DKG_ROUTE) {
388 button type="submit" class="btn btn-warning setup-btn" {
389 "🚀 Confirm"
390 }
391 }
392 }
393 }
394
395 div class="modal fade" id="qrScannerModal" tabindex="-1" aria-labelledby="qrScannerModalLabel" aria-hidden="true" {
397 div class="modal-dialog modal-dialog-centered" {
398 div class="modal-content" {
399 div class="modal-header" {
400 h5 class="modal-title" id="qrScannerModalLabel" { "Scan Setup Code" }
401 button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
402 }
403 div class="modal-body" {
404 div id="qr-reader" style="width: 100%;" {}
405 div id="qr-reader-error" class="alert alert-danger mt-3 d-none" {}
406 }
407 div class="modal-footer" {
408 button type="button" class="btn btn-secondary" data-bs-dismiss="modal" { "Cancel" }
409 }
410 }
411 }
412 }
413
414 script {
416 (PreEscaped(r#"
417 var html5QrCode = null;
418 var qrScannerModal = null;
419
420 function startQrScanner() {
421 // Check for Flutter override hook
422 if (typeof window.fedimintQrScannerOverride === 'function') {
423 window.fedimintQrScannerOverride(function(result) {
424 if (result) {
425 document.getElementById('peer_info').value = result;
426 }
427 });
428 return;
429 }
430
431 var modalEl = document.getElementById('qrScannerModal');
432 qrScannerModal = new bootstrap.Modal(modalEl);
433
434 // Reset error message
435 var errorEl = document.getElementById('qr-reader-error');
436 errorEl.classList.add('d-none');
437 errorEl.textContent = '';
438
439 qrScannerModal.show();
440
441 // Wait for modal to be shown before starting camera
442 modalEl.addEventListener('shown.bs.modal', function onShown() {
443 modalEl.removeEventListener('shown.bs.modal', onShown);
444 initializeScanner();
445 });
446
447 // Clean up when modal is hidden
448 modalEl.addEventListener('hidden.bs.modal', function onHidden() {
449 modalEl.removeEventListener('hidden.bs.modal', onHidden);
450 stopQrScanner();
451 });
452 }
453
454 function initializeScanner() {
455 html5QrCode = new Html5Qrcode("qr-reader");
456
457 var config = {
458 fps: 10,
459 qrbox: { width: 250, height: 250 },
460 aspectRatio: 1.0
461 };
462
463 html5QrCode.start(
464 { facingMode: "environment" },
465 config,
466 function(decodedText, decodedResult) {
467 // Success - populate input and close modal
468 document.getElementById('peer_info').value = decodedText;
469 qrScannerModal.hide();
470 },
471 function(errorMessage) {
472 // Ignore scan errors (happens constantly while searching)
473 }
474 ).catch(function(err) {
475 var errorEl = document.getElementById('qr-reader-error');
476 errorEl.textContent = 'Unable to access camera: ' + err;
477 errorEl.classList.remove('d-none');
478 });
479 }
480
481 function stopQrScanner() {
482 if (html5QrCode && html5QrCode.isScanning) {
483 html5QrCode.stop().catch(function(err) {
484 console.error('Error stopping scanner:', err);
485 });
486 }
487 }
488 "#))
489 }
490 };
491
492 Html(setup_layout("Federation Setup", content).into_string()).into_response()
493}
494
495async fn post_add_setup_code(
497 State(state): State<UiState<DynSetupApi>>,
498 _auth: UserAuth,
499 Form(input): Form<PeerInfoInput>,
500) -> impl IntoResponse {
501 match state.api.add_peer_setup_code(input.peer_info).await {
502 Ok(..) => Redirect::to(FEDERATION_SETUP_ROUTE).into_response(),
503 Err(e) => {
504 let content = html! {
505 div class="alert alert-danger" { (e.to_string()) }
506 div class="button-container" {
507 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
508 }
509 };
510
511 Html(setup_layout("Error", content).into_string()).into_response()
512 }
513 }
514}
515
516async fn post_start_dkg(
518 State(state): State<UiState<DynSetupApi>>,
519 _auth: UserAuth,
520) -> impl IntoResponse {
521 match state.api.start_dkg().await {
522 Ok(()) => {
523 let content = html! {
525 div class="alert alert-success my-4" {
526 "Setting up Federation..."
527 }
528
529 p class="text-center" {
530 "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
531 }
532
533 div
535 hx-get=(ROOT_ROUTE)
536 hx-trigger="every 2s"
537 hx-swap="none"
538 hx-on--after-request={
539 "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
540 }
541 style="display: none;"
542 {}
543
544 div class="text-center mt-4" {
545 div class="spinner-border text-primary" role="status" {
546 span class="visually-hidden" { "Loading..." }
547 }
548 p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
549 }
550 };
551
552 Html(setup_layout("DKG Started", content).into_string()).into_response()
553 }
554 Err(e) => {
555 let content = html! {
556 div class="alert alert-danger" { (e.to_string()) }
557 div class="button-container" {
558 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
559 }
560 };
561
562 Html(setup_layout("Error", content).into_string()).into_response()
563 }
564 }
565}
566
567async fn post_reset_setup_codes(
569 State(state): State<UiState<DynSetupApi>>,
570 _auth: UserAuth,
571) -> impl IntoResponse {
572 state.api.reset_setup_codes().await;
573
574 Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
575}
576
577pub fn router(api: DynSetupApi) -> Router {
578 Router::new()
579 .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
580 .route(LOGIN_ROUTE, get(login_form).post(login_submit))
581 .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
582 .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
583 .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
584 .route(START_DKG_ROUTE, post(post_start_dkg))
585 .with_static_routes()
586 .with_state(UiState::new(api))
587}