fedimint_server_ui/
setup.rs
1use std::future::Future;
2use std::net::SocketAddr;
3use std::pin::Pin;
4
5use axum::Router;
6use axum::extract::{Form, State};
7use axum::response::{Html, IntoResponse, Redirect};
8use axum::routing::{get, post};
9use axum_extra::extract::cookie::CookieJar;
10use fedimint_core::module::ApiAuth;
11use fedimint_core::task::TaskHandle;
12use fedimint_server_core::setup_ui::DynSetupApi;
13use maud::{DOCTYPE, Markup, html};
14use serde::Deserialize;
15use tokio::net::TcpListener;
16
17use crate::assets::WithStaticRoutesExt as _;
18use crate::{
19 AuthState, LoginInput, check_auth, layout, login_form_response, login_submit_response,
20};
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}
30
31#[derive(Debug, Deserialize)]
32pub(crate) struct PeerInfoInput {
33 pub peer_info: String,
34}
35
36pub fn setup_layout(title: &str, content: Markup) -> Markup {
37 html! {
38 (DOCTYPE)
39 html {
40 head {
41 (layout::common_head(title))
42 }
43 body {
44 div class="container" {
45 div class="row justify-content-center" {
46 div class="col-md-8 col-lg-5 narrow-container" {
47 header class="text-center" {
48 h1 class="header-title" { "Fedimint Guardian UI" }
49 }
50
51 div class="card" {
52 div class="card-body" {
53 (content)
54 }
55 }
56 }
57 }
58 }
59 script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
60 }
61 }
62 }
63}
64
65async fn setup_form(State(state): State<AuthState<DynSetupApi>>) -> impl IntoResponse {
67 if state.api.setup_code().await.is_some() {
68 return Redirect::to("/federation-setup").into_response();
69 }
70
71 let content = html! {
72 form method="post" action="/" {
73 style {
74 r#"
75 .toggle-content {
76 display: none;
77 }
78
79 .toggle-control:checked ~ .toggle-content {
80 display: block;
81 }
82 "#
83 }
84
85 div class="form-group mb-4" {
86 input type="text" class="form-control" id="name" name="name" placeholder="Guardian name" required;
87 }
88
89 div class="form-group mb-4" {
90 input type="password" class="form-control" id="password" name="password" placeholder="Secure password" required;
91 }
92
93 div class="form-group mb-4" {
94 div class="form-check" {
95 input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
96
97 label class="form-check-label" for="is_lead" {
98 "I am the guardian setting up the global configuration for this federation."
99 }
100
101 div class="toggle-content mt-3" {
102 input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation name";
103 }
104 }
105 }
106
107 div class="button-container" {
108 button type="submit" class="btn btn-primary setup-btn" { "Set Parameters" }
109 }
110 }
111 };
112
113 Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
114}
115
116async fn setup_submit(
118 State(state): State<AuthState<DynSetupApi>>,
119 Form(input): Form<SetupInput>,
120) -> impl IntoResponse {
121 let federation_name = if input.is_lead {
123 Some(input.federation_name)
124 } else {
125 None
126 };
127
128 match state
129 .api
130 .set_local_parameters(ApiAuth(input.password), input.name, federation_name)
131 .await
132 {
133 Ok(_) => Redirect::to("/login").into_response(),
134 Err(e) => {
135 let content = html! {
136 div class="alert alert-danger" { (e.to_string()) }
137 div class="button-container" {
138 a href="/" class="btn btn-primary setup-btn" { "Return to Setup" }
139 }
140 };
141
142 Html(setup_layout("Setup Error", content).into_string()).into_response()
143 }
144 }
145}
146
147async fn login_form(State(state): State<AuthState<DynSetupApi>>) -> impl IntoResponse {
149 if state.api.setup_code().await.is_none() {
150 return Redirect::to("/").into_response();
151 }
152
153 login_form_response().into_response()
154}
155
156async fn login_submit(
158 State(state): State<AuthState<DynSetupApi>>,
159 jar: CookieJar,
160 Form(input): Form<LoginInput>,
161) -> impl IntoResponse {
162 let auth = match state.api.auth().await {
163 Some(auth) => auth,
164 None => return Redirect::to("/").into_response(),
165 };
166
167 login_submit_response(
168 auth,
169 state.auth_cookie_name,
170 state.auth_cookie_value,
171 jar,
172 input,
173 )
174 .into_response()
175}
176
177async fn federation_setup(
179 State(state): State<AuthState<DynSetupApi>>,
180 jar: CookieJar,
181) -> impl IntoResponse {
182 if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
183 return Redirect::to("/login").into_response();
184 }
185
186 let our_connection_info = state
187 .api
188 .setup_code()
189 .await
190 .expect("Successful authentication ensures that the local parameters have been set");
191
192 let connected_peers = state.api.connected_peers().await;
193
194 let content = html! {
195 section class="mb-4" {
196 div class="alert alert-info mb-3" {
197 (our_connection_info)
198 }
199
200 div class="text-center" {
201 button type="button" class="btn btn-outline-primary setup-btn"
202 onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
203 "Copy to Clipboard"
204 }
205 }
206 }
207
208 hr class="my-4" {}
209
210 section class="mb-4" {
211 ul class="list-group mb-4" {
212 @for peer in connected_peers {
213 li class="list-group-item" { (peer) }
214 }
215 }
216
217 form method="post" action="/add-connection-info" {
218 div class="mb-3" {
219 input type="text" class="form-control mb-2" id="peer_info" name="peer_info"
220 placeholder="Paste setup code from fellow guardian" required;
221 }
222
223 div class="row mt-3" {
224 div class="col-6" {
225 button type="button" class="btn btn-warning w-100" onclick="document.getElementById('reset-form').submit();" {
226 "Reset Guardians"
227 }
228 }
229
230 div class="col-6" {
231 button type="submit" class="btn btn-primary w-100" { "Add Guardian" }
232 }
233 }
234 }
235
236 form id="reset-form" method="post" action="/reset-connection-info" class="d-none" {}
237 }
238
239 hr class="my-4" {}
240
241 section class="mb-4" {
242 div class="alert alert-warning mb-4" {
243 "Make sure all information is correct and every guardian is ready before launching the federation. This process cannot be reversed once started."
244 }
245
246 div class="text-center" {
247 form method="post" action="/start-dkg" {
248 button type="submit" class="btn btn-warning setup-btn" {
249 "🚀 Launch Federation"
250 }
251 }
252 }
253 }
254 };
255
256 Html(setup_layout("Federation Setup", content).into_string()).into_response()
257}
258
259async fn add_peer_handler(
261 State(state): State<AuthState<DynSetupApi>>,
262 jar: CookieJar,
263 Form(input): Form<PeerInfoInput>,
264) -> impl IntoResponse {
265 if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
266 return Redirect::to("/login").into_response();
267 }
268
269 match state.api.add_peer_setup_code(input.peer_info).await {
270 Ok(..) => Redirect::to("/federation-setup").into_response(),
271 Err(e) => {
272 let content = html! {
273 div class="alert alert-danger" { (e.to_string()) }
274 div class="button-container" {
275 a href="/federation-setup" class="btn btn-primary setup-btn" { "Return to Setup" }
276 }
277 };
278
279 Html(setup_layout("Error", content).into_string()).into_response()
280 }
281 }
282}
283
284async fn start_dkg_handler(
286 State(state): State<AuthState<DynSetupApi>>,
287 jar: CookieJar,
288) -> impl IntoResponse {
289 if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
290 return Redirect::to("/login").into_response();
291 }
292
293 match state.api.start_dkg().await {
294 Ok(()) => {
295 let content = html! {
297 div class="alert alert-success my-4" {
298 "The distributed key generation has been started successfully. You can monitor the progress in your server logs."
299 }
300 p class="text-center" {
301 "Once the distributed key generation completes, the Guardian Dashboard will become available at the root URL."
302 }
303 div class="button-container mt-4" {
304 a href="/" class="btn btn-primary setup-btn" {
305 "Go to Dashboard"
306 }
307 }
308 };
309
310 Html(setup_layout("DKG Started", content).into_string()).into_response()
311 }
312 Err(e) => {
313 let content = html! {
314 div class="alert alert-danger" { (e.to_string()) }
315 div class="button-container" {
316 a href="/federation-setup" class="btn btn-primary setup-btn" { "Return to Setup" }
317 }
318 };
319
320 Html(setup_layout("Error", content).into_string()).into_response()
321 }
322 }
323}
324
325async fn reset_peers_handler(
327 State(state): State<AuthState<DynSetupApi>>,
328 jar: CookieJar,
329) -> impl IntoResponse {
330 if !check_auth(&state.auth_cookie_name, &state.auth_cookie_value, &jar).await {
331 return Redirect::to("/login").into_response();
332 }
333
334 state.api.reset_setup_codes().await;
335
336 Redirect::to("/federation-setup").into_response()
337}
338
339pub fn start(
340 api: DynSetupApi,
341 ui_bind: SocketAddr,
342 task_handle: TaskHandle,
343) -> Pin<Box<dyn Future<Output = ()> + Send>> {
344 let app = Router::new()
345 .route("/", get(setup_form).post(setup_submit))
346 .route("/login", get(login_form).post(login_submit))
347 .route("/federation-setup", get(federation_setup))
348 .route("/add-connection-info", post(add_peer_handler))
349 .route("/reset-connection-info", post(reset_peers_handler))
350 .route("/start-dkg", post(start_dkg_handler))
351 .with_static_routes()
352 .with_state(AuthState::new(api));
353
354 Box::pin(async move {
355 let listener = TcpListener::bind(ui_bind)
356 .await
357 .expect("Failed to bind setup UI");
358
359 axum::serve(listener, app.into_make_service())
360 .with_graceful_shutdown(task_handle.make_shutdown_rx())
361 .await
362 .expect("Failed to serve setup UI");
363 })
364}