fedimint_server_ui/
setup.rs1use axum::Router;
2use axum::extract::{Form, State};
3use axum::response::{Html, IntoResponse, Redirect};
4use axum::routing::{get, post};
5use axum_extra::extract::cookie::CookieJar;
6use fedimint_core::module::ApiAuth;
7use fedimint_server_core::setup_ui::DynSetupApi;
8use fedimint_ui_common::assets::WithStaticRoutesExt;
9use fedimint_ui_common::auth::UserAuth;
10use fedimint_ui_common::{LOGIN_ROUTE, LoginInput, ROOT_ROUTE, UiState, login_form_response};
11use maud::{DOCTYPE, Markup, html};
12use serde::Deserialize;
13
14use crate::{common_head, login_submit_response};
15
16pub const FEDERATION_SETUP_ROUTE: &str = "/federation_setup";
18pub const ADD_SETUP_CODE_ROUTE: &str = "/add_setup_code";
19pub const RESET_SETUP_CODES_ROUTE: &str = "/reset_setup_codes";
20pub const START_DKG_ROUTE: &str = "/start_dkg";
21
22#[derive(Debug, Deserialize)]
23pub(crate) struct SetupInput {
24 pub password: String,
25 pub name: String,
26 #[serde(default)]
27 pub is_lead: bool,
28 pub federation_name: String,
29 #[serde(default)] pub enable_base_fees: bool,
31}
32
33#[derive(Debug, Deserialize)]
34pub(crate) struct PeerInfoInput {
35 pub peer_info: String,
36}
37
38pub fn setup_layout(title: &str, content: Markup) -> Markup {
39 html! {
40 (DOCTYPE)
41 html {
42 head {
43 (common_head(title))
44 }
45 body {
46 div class="container" {
47 div class="row justify-content-center" {
48 div class="col-md-8 col-lg-5 narrow-container" {
49 header class="text-center" {
50 h1 class="header-title" { "Fedimint Guardian UI" }
51 }
52
53 div class="card" {
54 div class="card-body" {
55 (content)
56 }
57 }
58 }
59 }
60 }
61 script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
62 }
63 }
64 }
65}
66
67async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
69 if state.api.setup_code().await.is_some() {
70 return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
71 }
72
73 let content = html! {
74 form method="post" action=(ROOT_ROUTE) {
75 style {
76 r#"
77 .toggle-content {
78 display: none;
79 }
80
81 .toggle-control:checked ~ .toggle-content {
82 display: block;
83 }
84
85 #base-fees-warning {
86 display: block;
87 }
88
89 .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
90 /* hide the warning if the fees are enabled */
91 display: none;
92 }
93 "#
94 }
95
96 div class="form-group mb-4" {
97 input type="text" class="form-control" id="name" name="name" placeholder="Your guardian name" required;
98 }
99
100 div class="form-group mb-4" {
101 input type="password" class="form-control" id="password" name="password" placeholder="Set password" required;
102 }
103
104 div class="form-group mb-4" {
105 div class="form-check" {
106 input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
107
108 label class="form-check-label" for="is_lead" {
109 "Set global configuration. "
110 b { "Only one guardian must enable this." }
111 }
112
113 div class="toggle-content mt-3" {
114 input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation name";
115
116 div class="form-check mt-3" {
117 input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
118
119 label class="form-check-label" for="enable_base_fees" {
120 "Enable base fees for this federation"
121 }
122 }
123
124 div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
125 strong { "Warning: " }
126 "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."
127 }
128 }
129 }
130 }
131
132 div class="button-container" {
133 button type="submit" class="btn btn-primary setup-btn" { "OK" }
134 }
135 }
136 };
137
138 Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
139}
140
141async fn setup_submit(
143 State(state): State<UiState<DynSetupApi>>,
144 Form(input): Form<SetupInput>,
145) -> impl IntoResponse {
146 let federation_name = if input.is_lead {
148 Some(input.federation_name)
149 } else {
150 None
151 };
152
153 let disable_base_fees = if input.is_lead {
155 Some(!input.enable_base_fees)
156 } else {
157 None
158 };
159
160 match state
161 .api
162 .set_local_parameters(
163 ApiAuth(input.password),
164 input.name,
165 federation_name,
166 disable_base_fees,
167 )
168 .await
169 {
170 Ok(_) => Redirect::to(LOGIN_ROUTE).into_response(),
171 Err(e) => {
172 let content = html! {
173 div class="alert alert-danger" { (e.to_string()) }
174 div class="button-container" {
175 a href=(ROOT_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
176 }
177 };
178
179 Html(setup_layout("Setup Error", content).into_string()).into_response()
180 }
181 }
182}
183
184async fn login_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
186 if state.api.setup_code().await.is_none() {
187 return Redirect::to(ROOT_ROUTE).into_response();
188 }
189
190 login_form_response("Fedimint Guardian Login").into_response()
191}
192
193async fn login_submit(
195 State(state): State<UiState<DynSetupApi>>,
196 jar: CookieJar,
197 Form(input): Form<LoginInput>,
198) -> impl IntoResponse {
199 let auth = match state.api.auth().await {
200 Some(auth) => auth,
201 None => return Redirect::to(ROOT_ROUTE).into_response(),
202 };
203
204 login_submit_response(
205 auth,
206 state.auth_cookie_name,
207 state.auth_cookie_value,
208 jar,
209 input,
210 )
211 .into_response()
212}
213
214async fn federation_setup(
216 State(state): State<UiState<DynSetupApi>>,
217 _auth: UserAuth,
218) -> impl IntoResponse {
219 let our_connection_info = state
220 .api
221 .setup_code()
222 .await
223 .expect("Successful authentication ensures that the local parameters have been set");
224
225 let connected_peers = state.api.connected_peers().await;
226
227 let content = html! {
228 section class="mb-4" {
229 h4 { "Your setup code" }
230
231 p { "Share it with other guardians." }
232 div class="alert alert-info mb-3" {
233 (our_connection_info)
234 }
235
236 div class="text-center" {
237 button type="button" class="btn btn-outline-primary setup-btn"
238 onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
239 "Copy to Clipboard"
240 }
241 }
242 }
243
244 hr class="my-4" {}
245
246 section class="mb-4" {
247 h4 { "Other guardians" }
248
249 p { "Add setup code of every other guardian." }
250
251 ul class="list-group mb-4" {
252 @for peer in connected_peers {
253 li class="list-group-item" { (peer) }
254 }
255 }
256
257 form method="post" action=(ADD_SETUP_CODE_ROUTE) {
258 div class="mb-3" {
259 input type="text" class="form-control mb-2" id="peer_info" name="peer_info"
260 placeholder="Paste setup code" required;
261 }
262
263 div class="row mt-3" {
264 div class="col-6" {
265 button type="button" class="btn btn-warning w-100" onclick="document.getElementById('reset-form').submit();" {
266 "Reset Guardians"
267 }
268 }
269
270 div class="col-6" {
271 button type="submit" class="btn btn-primary w-100" { "Add Guardian" }
272 }
273 }
274 }
275
276 form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
277 }
278
279 hr class="my-4" {}
280
281 section class="mb-4" {
282 div class="alert alert-warning mb-4" {
283 "Verify " b { "all" } " other guardians were added. This process cannot be reversed once started."
284 }
285
286 div class="text-center" {
287 form method="post" action=(START_DKG_ROUTE) {
288 button type="submit" class="btn btn-warning setup-btn" {
289 "🚀 Confirm"
290 }
291 }
292 }
293 }
294 };
295
296 Html(setup_layout("Federation Setup", content).into_string()).into_response()
297}
298
299async fn post_add_setup_code(
301 State(state): State<UiState<DynSetupApi>>,
302 _auth: UserAuth,
303 Form(input): Form<PeerInfoInput>,
304) -> impl IntoResponse {
305 match state.api.add_peer_setup_code(input.peer_info).await {
306 Ok(..) => Redirect::to(FEDERATION_SETUP_ROUTE).into_response(),
307 Err(e) => {
308 let content = html! {
309 div class="alert alert-danger" { (e.to_string()) }
310 div class="button-container" {
311 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
312 }
313 };
314
315 Html(setup_layout("Error", content).into_string()).into_response()
316 }
317 }
318}
319
320async fn post_start_dkg(
322 State(state): State<UiState<DynSetupApi>>,
323 _auth: UserAuth,
324) -> impl IntoResponse {
325 match state.api.start_dkg().await {
326 Ok(()) => {
327 let content = html! {
329 div class="alert alert-success my-4" {
330 "Setting up Federation..."
331 }
332
333 p class="text-center" {
334 "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
335 }
336
337 div
339 hx-get=(ROOT_ROUTE)
340 hx-trigger="every 2s"
341 hx-swap="none"
342 hx-on--after-request={
343 "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
344 }
345 style="display: none;"
346 {}
347
348 div class="text-center mt-4" {
349 div class="spinner-border text-primary" role="status" {
350 span class="visually-hidden" { "Loading..." }
351 }
352 p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
353 }
354 };
355
356 Html(setup_layout("DKG Started", content).into_string()).into_response()
357 }
358 Err(e) => {
359 let content = html! {
360 div class="alert alert-danger" { (e.to_string()) }
361 div class="button-container" {
362 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
363 }
364 };
365
366 Html(setup_layout("Error", content).into_string()).into_response()
367 }
368 }
369}
370
371async fn post_reset_setup_codes(
373 State(state): State<UiState<DynSetupApi>>,
374 _auth: UserAuth,
375) -> impl IntoResponse {
376 state.api.reset_setup_codes().await;
377
378 Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
379}
380
381pub fn router(api: DynSetupApi) -> Router {
382 Router::new()
383 .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
384 .route(LOGIN_ROUTE, get(login_form).post(login_submit))
385 .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
386 .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
387 .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
388 .route(START_DKG_ROUTE, post(post_start_dkg))
389 .with_static_routes()
390 .with_state(UiState::new(api))
391}