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