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, html};
16use serde::Deserialize;
17
18use crate::{common_head, login_submit_response};
19
20pub const FEDERATION_SETUP_ROUTE: &str = "/federation_setup";
22pub const ADD_SETUP_CODE_ROUTE: &str = "/add_setup_code";
23pub const RESET_SETUP_CODES_ROUTE: &str = "/reset_setup_codes";
24pub const START_DKG_ROUTE: &str = "/start_dkg";
25
26#[derive(Debug, Deserialize)]
27pub(crate) struct SetupInput {
28 pub password: String,
29 pub name: String,
30 #[serde(default)]
31 pub is_lead: bool,
32 pub federation_name: String,
33 #[serde(default)] pub enable_base_fees: bool,
35 #[serde(default)] pub enabled_modules: Vec<String>,
37}
38
39#[derive(Debug, Deserialize)]
40pub(crate) struct PeerInfoInput {
41 pub peer_info: String,
42}
43
44pub fn setup_layout(title: &str, content: Markup) -> Markup {
45 html! {
46 (DOCTYPE)
47 html {
48 head {
49 (common_head(title))
50 }
51 body {
52 div class="container" {
53 div class="row justify-content-center" {
54 div class="col-md-8 col-lg-5 narrow-container" {
55 header class="text-center" {
56 h1 class="header-title" { "Fedimint Guardian UI" }
57 }
58
59 div class="card" {
60 div class="card-body" {
61 (content)
62 }
63 }
64 }
65 }
66 }
67 script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
68 }
69 }
70 }
71}
72
73async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
75 if state.api.setup_code().await.is_some() {
76 return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
77 }
78
79 let available_modules = state.api.available_modules();
80
81 let content = html! {
82 form method="post" action=(ROOT_ROUTE) {
83 style {
84 r#"
85 .toggle-content {
86 display: none;
87 }
88
89 .toggle-control:checked ~ .toggle-content {
90 display: block;
91 }
92
93 #base-fees-warning {
94 display: block;
95 }
96
97 .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
98 display: none;
99 }
100
101 .accordion-button {
102 background-color: #f8f9fa;
103 }
104
105 .accordion-button:not(.collapsed) {
106 background-color: #f8f9fa;
107 box-shadow: none;
108 }
109
110 .accordion-button:focus {
111 box-shadow: none;
112 }
113
114 #modules-warning {
115 display: none;
116 }
117
118 #modules-list:has(.form-check-input:not(:checked)) ~ #modules-warning {
119 display: block;
120 }
121 "#
122 }
123
124 div class="form-group mb-4" {
125 input type="text" class="form-control" id="name" name="name" placeholder="Your Guardian Name" required;
126 }
127
128 div class="form-group mb-4" {
129 input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required;
130 }
131
132 div class="alert alert-warning mb-3" style="font-size: 0.875rem;" {
133 "Exactly one guardian must set the global config."
134 }
135
136 div class="form-group mb-4" {
137 input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
138
139 label class="form-check-label ms-2" for="is_lead" {
140 "Set the global config"
141 }
142
143 div class="toggle-content mt-3" {
144 input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation Name";
145
146 div class="form-check mt-3" {
147 input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
148
149 label class="form-check-label" for="enable_base_fees" {
150 "Enable base fees for this federation"
151 }
152 }
153
154 div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
155 strong { "Warning: " }
156 "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."
157 }
158
159 div class="accordion mt-3" id="modulesAccordion" {
160 div class="accordion-item" {
161 h2 class="accordion-header" {
162 button class="accordion-button collapsed" type="button"
163 data-bs-toggle="collapse" data-bs-target="#modulesConfig"
164 aria-expanded="false" aria-controls="modulesConfig" {
165 "Advanced: Configure Enabled Modules"
166 }
167 }
168 div id="modulesConfig" class="accordion-collapse collapse" data-bs-parent="#modulesAccordion" {
169 div class="accordion-body" {
170 div id="modules-list" {
171 @for kind in &available_modules {
172 div class="form-check" {
173 input type="checkbox" class="form-check-input"
174 id=(format!("module_{}", kind.as_str()))
175 name="enabled_modules"
176 value=(kind.as_str())
177 checked;
178
179 label class="form-check-label" for=(format!("module_{}", kind.as_str())) {
180 (kind.as_str())
181 }
182 }
183 }
184 }
185
186 div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
187 "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
188 }
189 }
190 }
191 }
192 }
193 }
194 }
195
196 div class="button-container" {
197 button type="submit" class="btn btn-primary setup-btn" { "Confirm" }
198 }
199 }
200 };
201
202 Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
203}
204
205async fn setup_submit(
207 State(state): State<UiState<DynSetupApi>>,
208 Form(input): Form<SetupInput>,
209) -> impl IntoResponse {
210 let federation_name = if input.is_lead {
212 Some(input.federation_name)
213 } else {
214 None
215 };
216
217 let disable_base_fees = if input.is_lead {
218 Some(!input.enable_base_fees)
219 } else {
220 None
221 };
222
223 let enabled_modules = if input.is_lead {
224 let enabled: BTreeSet<ModuleKind> = input
225 .enabled_modules
226 .into_iter()
227 .map(|s| ModuleKind::clone_from_str(&s))
228 .collect();
229
230 Some(enabled)
231 } else {
232 None
233 };
234
235 match state
236 .api
237 .set_local_parameters(
238 ApiAuth(input.password),
239 input.name,
240 federation_name,
241 disable_base_fees,
242 enabled_modules,
243 )
244 .await
245 {
246 Ok(_) => Redirect::to(LOGIN_ROUTE).into_response(),
247 Err(e) => {
248 let content = html! {
249 div class="alert alert-danger" { (e.to_string()) }
250 div class="button-container" {
251 a href=(ROOT_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
252 }
253 };
254
255 Html(setup_layout("Setup Error", content).into_string()).into_response()
256 }
257 }
258}
259
260async fn login_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
262 if state.api.setup_code().await.is_none() {
263 return Redirect::to(ROOT_ROUTE).into_response();
264 }
265
266 login_form_response("Fedimint Guardian Login").into_response()
267}
268
269async fn login_submit(
271 State(state): State<UiState<DynSetupApi>>,
272 jar: CookieJar,
273 Form(input): Form<LoginInput>,
274) -> impl IntoResponse {
275 let auth = match state.api.auth().await {
276 Some(auth) => auth,
277 None => return Redirect::to(ROOT_ROUTE).into_response(),
278 };
279
280 login_submit_response(
281 auth,
282 state.auth_cookie_name,
283 state.auth_cookie_value,
284 jar,
285 input,
286 )
287 .into_response()
288}
289
290async fn federation_setup(
292 State(state): State<UiState<DynSetupApi>>,
293 _auth: UserAuth,
294) -> impl IntoResponse {
295 let our_connection_info = state
296 .api
297 .setup_code()
298 .await
299 .expect("Successful authentication ensures that the local parameters have been set");
300
301 let connected_peers = state.api.connected_peers().await;
302
303 let content = html! {
304 section class="mb-4" {
305 h4 { "Your setup code" }
306
307 p { "Share it with other guardians." }
308 div class="alert alert-info mb-3" {
309 (our_connection_info)
310 }
311
312 div class="text-center" {
313 button type="button" class="btn btn-outline-primary setup-btn"
314 onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
315 "Copy to Clipboard"
316 }
317 }
318 }
319
320 hr class="my-4" {}
321
322 section class="mb-4" {
323 h4 { "Other guardians" }
324
325 p { "Add setup code of every other guardian." }
326
327 ul class="list-group mb-4" {
328 @for peer in connected_peers {
329 li class="list-group-item" { (peer) }
330 }
331 }
332
333 form method="post" action=(ADD_SETUP_CODE_ROUTE) {
334 div class="mb-3" {
335 input type="text" class="form-control mb-2" id="peer_info" name="peer_info"
336 placeholder="Paste setup code" required;
337 }
338
339 div class="row mt-3" {
340 div class="col-6" {
341 button type="button" class="btn btn-warning w-100" onclick="document.getElementById('reset-form').submit();" {
342 "Reset Guardians"
343 }
344 }
345
346 div class="col-6" {
347 button type="submit" class="btn btn-primary w-100" { "Add Guardian" }
348 }
349 }
350 }
351
352 form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
353 }
354
355 hr class="my-4" {}
356
357 section class="mb-4" {
358 div class="alert alert-warning mb-4" {
359 "Verify " b { "all" } " other guardians were added. This process cannot be reversed once started."
360 }
361
362 div class="text-center" {
363 form method="post" action=(START_DKG_ROUTE) {
364 button type="submit" class="btn btn-warning setup-btn" {
365 "🚀 Confirm"
366 }
367 }
368 }
369 }
370 };
371
372 Html(setup_layout("Federation Setup", content).into_string()).into_response()
373}
374
375async fn post_add_setup_code(
377 State(state): State<UiState<DynSetupApi>>,
378 _auth: UserAuth,
379 Form(input): Form<PeerInfoInput>,
380) -> impl IntoResponse {
381 match state.api.add_peer_setup_code(input.peer_info).await {
382 Ok(..) => Redirect::to(FEDERATION_SETUP_ROUTE).into_response(),
383 Err(e) => {
384 let content = html! {
385 div class="alert alert-danger" { (e.to_string()) }
386 div class="button-container" {
387 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
388 }
389 };
390
391 Html(setup_layout("Error", content).into_string()).into_response()
392 }
393 }
394}
395
396async fn post_start_dkg(
398 State(state): State<UiState<DynSetupApi>>,
399 _auth: UserAuth,
400) -> impl IntoResponse {
401 match state.api.start_dkg().await {
402 Ok(()) => {
403 let content = html! {
405 div class="alert alert-success my-4" {
406 "Setting up Federation..."
407 }
408
409 p class="text-center" {
410 "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
411 }
412
413 div
415 hx-get=(ROOT_ROUTE)
416 hx-trigger="every 2s"
417 hx-swap="none"
418 hx-on--after-request={
419 "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
420 }
421 style="display: none;"
422 {}
423
424 div class="text-center mt-4" {
425 div class="spinner-border text-primary" role="status" {
426 span class="visually-hidden" { "Loading..." }
427 }
428 p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
429 }
430 };
431
432 Html(setup_layout("DKG Started", content).into_string()).into_response()
433 }
434 Err(e) => {
435 let content = html! {
436 div class="alert alert-danger" { (e.to_string()) }
437 div class="button-container" {
438 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
439 }
440 };
441
442 Html(setup_layout("Error", content).into_string()).into_response()
443 }
444 }
445}
446
447async fn post_reset_setup_codes(
449 State(state): State<UiState<DynSetupApi>>,
450 _auth: UserAuth,
451) -> impl IntoResponse {
452 state.api.reset_setup_codes().await;
453
454 Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
455}
456
457pub fn router(api: DynSetupApi) -> Router {
458 Router::new()
459 .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
460 .route(LOGIN_ROUTE, get(login_form).post(login_submit))
461 .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
462 .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
463 .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
464 .route(START_DKG_ROUTE, post(post_start_dkg))
465 .with_static_routes()
466 .with_state(UiState::new(api))
467}