fedimint_gateway_ui/
connect_fed.rs1use std::fmt::Display;
2
3use axum::extract::State;
4use axum::http::{HeaderMap, header};
5use axum::response::IntoResponse;
6use axum::{Form, Json};
7use fedimint_gateway_common::ConnectFedPayload;
8use fedimint_ui_common::UiState;
9use fedimint_ui_common::auth::UserAuth;
10use maud::{Markup, PreEscaped, html};
11use serde::Serialize;
12
13use crate::{
14 CONNECT_FEDERATION_ROUTE, DynGatewayApi, redirect_error, redirect_success_with_export_reminder,
15};
16
17#[derive(Serialize)]
18struct ConnectFedResponse {
19 status: String,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 federation_info: Option<fedimint_gateway_common::FederationInfo>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 error: Option<String>,
24}
25
26pub fn render(gateway_state: &str) -> Markup {
27 let is_running = gateway_state == "Running";
28
29 html!(
30 div class="card h-100" {
31 div class="card-header dashboard-header" { "Connect a new Federation" }
32 div class="card-body" {
33 form method="post" action=(CONNECT_FEDERATION_ROUTE)
34 onsubmit="var btn = this.querySelector('button[type=submit]'); \
35 var isRecover = this.querySelector('#recover-checkbox').checked; \
36 btn.disabled = true; \
37 btn.innerHTML = '<span class=\"spinner-border spinner-border-sm\" role=\"status\"></span> ' + (isRecover ? 'Recovering...' : 'Connecting...');"
38 {
39 div class="mb-3" {
40 label class="form-label" { "Invite Code" }
41 input type="text" class="form-control" name="invite_code" required;
42 }
43 div class="mb-3 form-check" {
44 input type="checkbox" class="form-check-input" name="recover" value="true" id="recover-checkbox";
45 label class="form-check-label" for="recover-checkbox" { "Recover" }
46 }
47 div class="d-flex gap-2" {
48 button type="submit" class="btn btn-primary" { "Submit" }
49 @if is_running {
50 button type="button"
51 class="btn btn-warning"
52 onclick="showImportModal();"
53 {
54 "Recover from File"
55 }
56 } @else {
57 button type="button"
58 class="btn btn-warning"
59 disabled
60 data-bs-toggle="tooltip"
61 data-bs-placement="top"
62 title="Gateway must be in Running state to import and recover federations"
63 {
64 "Recover from File"
65 }
66 }
67 }
68 }
69 }
70 }
71
72 div class="modal fade" id="importModal" tabindex="-1" aria-labelledby="importModalLabel" aria-hidden="true" {
74 div class="modal-dialog modal-dialog-centered modal-lg" {
75 div class="modal-content" {
76 div class="modal-header" {
77 h5 class="modal-title" id="importModalLabel" { "Recover Federations" }
78 button type="button" id="modalCloseBtn" class="btn-close" onclick="hideImportModal();" aria-label="Close" {}
79 }
80 div class="modal-body" {
81 div id="fileSelectionView" {
83 div class="alert alert-warning" role="alert" {
84 strong { "Important: " }
85 "The recovery process may take some time per federation. Please be patient and do not close this window until the process is complete."
86 }
87 div class="mb-3" {
88 label class="form-label" for="inviteCodesFile" {
89 "Select Invite Codes Backup File (JSON)"
90 }
91 input
92 type="file"
93 class="form-control"
94 id="inviteCodesFile"
95 name="invite_codes"
96 accept=".json,application/json"
97 required;
98 div class="form-text" {
99 "Upload the gateway-invite-codes.json file exported from this or another gateway."
100 }
101 }
102 }
103
104 div id="progressView" class="d-none" {
106 div class="alert alert-info" role="alert" {
107 "Recovery is in progress. This may take some time. Please wait..."
108 }
109 div class="mb-3" {
110 div class="d-flex justify-content-between mb-2" {
111 span id="progressText" { "Processing 0 of 0 federations..." }
112 span id="progressPercent" { "0%" }
113 }
114 div class="progress" {
115 div
116 id="progressBar"
117 class="progress-bar progress-bar-striped progress-bar-animated"
118 role="progressbar"
119 style="width: 0%"
120 aria-valuenow="0"
121 aria-valuemin="0"
122 aria-valuemax="100"
123 {}
124 }
125 }
126 div id="federationStatusList" class="list-group" {}
127 }
128
129 div id="resultsView" class="d-none" {
131 div id="resultsSummary" class="alert" {}
132 div id="resultsDetails" class="mb-3" {}
133 }
134 }
135 div class="modal-footer" {
136 button type="button" id="cancelBtn" class="btn btn-secondary" onclick="hideImportModal();" { "Cancel" }
137 button
138 type="button"
139 id="startRecoveryBtn"
140 class="btn btn-warning"
141 onclick="startRecovery();"
142 {
143 "Recover Federations"
144 }
145 button
146 type="button"
147 id="closeResultsBtn"
148 class="btn btn-primary d-none"
149 onclick="hideImportModal();"
150 {
151 "Close"
152 }
153 }
154 }
155 }
156 }
157
158 script {
160 (PreEscaped(r#"
161 var importModal = null;
162 var recoveryData = null;
163 var recoveryResults = {
164 recovered: [],
165 skipped: [],
166 failed: []
167 };
168
169 function showImportModal() {
170 var modalEl = document.getElementById('importModal');
171 if (!importModal) {
172 importModal = new bootstrap.Modal(modalEl, {
173 backdrop: 'static',
174 keyboard: false
175 });
176 }
177 resetModalState();
178 importModal.show();
179 }
180
181 function hideImportModal() {
182 // Check if we're showing results (recovery completed)
183 var resultsView = document.getElementById('resultsView');
184 var shouldRefresh = resultsView && !resultsView.classList.contains('d-none');
185
186 if (importModal) {
187 importModal.hide();
188 }
189
190 // Refresh the page to show updated federation list if recovery completed
191 if (shouldRefresh) {
192 window.location.reload();
193 }
194 }
195
196 function resetModalState() {
197 document.getElementById('inviteCodesFile').value = '';
198 document.getElementById('fileSelectionView').classList.remove('d-none');
199 document.getElementById('progressView').classList.add('d-none');
200 document.getElementById('resultsView').classList.add('d-none');
201 document.getElementById('cancelBtn').classList.remove('d-none');
202 document.getElementById('startRecoveryBtn').classList.remove('d-none');
203 document.getElementById('closeResultsBtn').classList.add('d-none');
204 document.getElementById('modalCloseBtn').classList.remove('d-none');
205 document.getElementById('federationStatusList').innerHTML = '';
206 document.getElementById('resultsSummary').innerHTML = '';
207 document.getElementById('resultsDetails').innerHTML = '';
208 recoveryData = null;
209 recoveryResults = { recovered: [], skipped: [], failed: [] };
210 }
211
212 async function startRecovery() {
213 var fileInput = document.getElementById('inviteCodesFile');
214
215 // Validate file
216 if (!fileInput.files || fileInput.files.length === 0) {
217 alert('Please select a file to upload.');
218 return;
219 }
220
221 var file = fileInput.files[0];
222
223 // Validate file type
224 if (!file.name.endsWith('.json') && file.type !== 'application/json') {
225 alert('Please select a valid JSON file.');
226 return;
227 }
228
229 // Validate file size (1MB max)
230 var maxSize = 1024 * 1024;
231 if (file.size > maxSize) {
232 alert('File too large. Maximum size is 1MB.');
233 return;
234 }
235
236 // Read and parse file
237 try {
238 var content = await file.text();
239 recoveryData = JSON.parse(content);
240 } catch (e) {
241 alert('Failed to parse JSON file: ' + e.message);
242 return;
243 }
244
245 // Validate data structure
246 var federationIds = Object.keys(recoveryData);
247 if (federationIds.length === 0) {
248 alert('No federations found in the uploaded file.');
249 return;
250 }
251
252 // Switch to progress view and disable modal dismissal
253 document.getElementById('fileSelectionView').classList.add('d-none');
254 document.getElementById('progressView').classList.remove('d-none');
255 document.getElementById('startRecoveryBtn').classList.add('d-none');
256 document.getElementById('cancelBtn').classList.add('d-none');
257 document.getElementById('modalCloseBtn').classList.add('d-none');
258
259 // Initialize progress
260 updateProgress(0, federationIds.length);
261
262 // Create status list items
263 var statusList = document.getElementById('federationStatusList');
264 federationIds.forEach(function(fedId) {
265 var item = document.createElement('div');
266 item.className = 'list-group-item d-flex justify-content-between align-items-center';
267 item.id = 'fed-status-' + fedId;
268 item.innerHTML = '<span>' + fedId + '</span><span class="badge bg-secondary">Pending</span>';
269 statusList.appendChild(item);
270 });
271
272 // Process federations sequentially
273 for (var i = 0; i < federationIds.length; i++) {
274 var fedId = federationIds[i];
275 updateProgress(i, federationIds.length);
276 await processFederation(fedId, recoveryData[fedId]);
277 }
278
279 // Complete
280 updateProgress(federationIds.length, federationIds.length);
281 showResults();
282 }
283
284 function updateProgress(current, total) {
285 var percent = total > 0 ? Math.round((current / total) * 100) : 0;
286 document.getElementById('progressText').textContent = 'Processing ' + current + ' of ' + total + ' federations...';
287 document.getElementById('progressPercent').textContent = percent + '%';
288 var progressBar = document.getElementById('progressBar');
289 progressBar.style.width = percent + '%';
290 progressBar.setAttribute('aria-valuenow', percent);
291 }
292
293 async function processFederation(federationId, inviteCodes) {
294 var statusEl = document.getElementById('fed-status-' + federationId);
295 if (statusEl) {
296 statusEl.querySelector('.badge').className = 'badge bg-info';
297 statusEl.querySelector('.badge').textContent = 'Processing...';
298 }
299
300 // Check if there are invite codes
301 if (!inviteCodes || inviteCodes.length === 0) {
302 recoveryResults.failed.push({ id: federationId, error: 'No invite codes available' });
303 updateFederationStatus(federationId, 'failed', 'No invite codes');
304 return;
305 }
306
307 // Create URL-encoded form data
308 var formData = new URLSearchParams();
309 formData.append('invite_code', inviteCodes[0]);
310 formData.append('recover', 'true');
311
312 try {
313 var response = await fetch('/ui/federations/join', {
314 method: 'POST',
315 headers: {
316 'Accept': 'application/json',
317 'Content-Type': 'application/x-www-form-urlencoded'
318 },
319 body: formData.toString()
320 });
321
322 // Check if response is JSON
323 var contentType = response.headers.get('content-type');
324 if (!contentType || !contentType.includes('application/json')) {
325 // Not JSON - likely an error page or redirect
326 var text = await response.text();
327 console.error('Non-JSON response:', text.substring(0, 200));
328 recoveryResults.failed.push({
329 id: federationId,
330 error: 'Server returned non-JSON response (status: ' + response.status + ')'
331 });
332 updateFederationStatus(federationId, 'failed', 'Server error (status: ' + response.status + ')');
333 return;
334 }
335
336 var result = await response.json();
337
338 if (result.status === 'success') {
339 var fedName = result.federation_info && result.federation_info.federation_name ?
340 result.federation_info.federation_name : federationId;
341 recoveryResults.recovered.push({ id: federationId, name: fedName });
342 updateFederationStatus(federationId, 'success', 'Recovered');
343 } else {
344 recoveryResults.failed.push({ id: federationId, error: result.error || 'Unknown error' });
345 updateFederationStatus(federationId, 'failed', result.error || 'Failed');
346 }
347 } catch (e) {
348 console.error('Error processing federation:', federationId, e);
349 recoveryResults.failed.push({ id: federationId, error: e.message });
350 updateFederationStatus(federationId, 'failed', 'Network error');
351 }
352 }
353
354 function updateFederationStatus(fedId, status, message) {
355 var statusEl = document.getElementById('fed-status-' + fedId);
356 if (statusEl) {
357 var badge = statusEl.querySelector('.badge');
358 if (status === 'success') {
359 badge.className = 'badge bg-success';
360 } else if (status === 'skipped') {
361 badge.className = 'badge bg-warning text-dark';
362 } else if (status === 'failed') {
363 badge.className = 'badge bg-danger';
364 }
365 badge.textContent = message;
366 }
367 }
368
369 function showResults() {
370 document.getElementById('progressView').classList.add('d-none');
371 document.getElementById('resultsView').classList.remove('d-none');
372 document.getElementById('closeResultsBtn').classList.remove('d-none');
373 document.getElementById('modalCloseBtn').classList.remove('d-none');
374
375 var total = recoveryResults.recovered.length + recoveryResults.skipped.length + recoveryResults.failed.length;
376 var summaryEl = document.getElementById('resultsSummary');
377 var detailsEl = document.getElementById('resultsDetails');
378
379 // Set summary
380 if (recoveryResults.failed.length === 0) {
381 summaryEl.className = 'alert alert-success';
382 summaryEl.innerHTML = '<strong>Success!</strong> All federations have been recovered.';
383 } else if (recoveryResults.recovered.length === 0) {
384 summaryEl.className = 'alert alert-danger';
385 summaryEl.innerHTML = '<strong>Error!</strong> No federations could be recovered. Please try again later.';
386 } else {
387 summaryEl.className = 'alert alert-warning';
388 summaryEl.innerHTML = '<strong>Partial Success!</strong> Some federations were recovered, but there were failures. Please try again later.';
389 }
390
391 // Build details
392 var details = [];
393 if (recoveryResults.recovered.length > 0) {
394 details.push('<p><strong>Recovered (' + recoveryResults.recovered.length + '):</strong> ' +
395 recoveryResults.recovered.map(function(r) { return r.name || r.id; }).join(', ') + '</p>');
396 }
397 if (recoveryResults.skipped.length > 0) {
398 details.push('<p><strong>Skipped - Already Joined (' + recoveryResults.skipped.length + '):</strong> ' +
399 recoveryResults.skipped.join(', ') + '</p>');
400 }
401 if (recoveryResults.failed.length > 0) {
402 var failedList = recoveryResults.failed.map(function(f) {
403 return f.id + ' (' + (f.error || 'Unknown error') + ')';
404 }).join(', ');
405 details.push('<p><strong>Failed (' + recoveryResults.failed.length + '):</strong> ' + failedList + '</p>');
406 details.push('<p class="text-muted mt-2">Please try again later to recover the failed federations.</p>');
407 }
408
409 detailsEl.innerHTML = details.join('');
410 }
411 "#))
412 }
413 )
414}
415
416pub async fn connect_federation_handler<E: Display>(
417 headers: HeaderMap,
418 State(state): State<UiState<DynGatewayApi<E>>>,
419 _auth: UserAuth,
420 Form(payload): Form<ConnectFedPayload>,
421) -> impl IntoResponse {
422 let accepts_json = headers
423 .get(header::ACCEPT)
424 .and_then(|v| v.to_str().ok())
425 .map(|v| v.contains("application/json"))
426 .unwrap_or(false);
427
428 match state.api.handle_connect_federation(payload).await {
429 Ok(info) => {
430 if accepts_json {
431 Json(ConnectFedResponse {
432 status: "success".to_string(),
433 federation_info: Some(info),
434 error: None,
435 })
436 .into_response()
437 } else {
438 redirect_success_with_export_reminder(format!(
439 "Successfully joined {}.",
440 info.federation_name
441 .unwrap_or("Unnamed Federation".to_string())
442 ))
443 .into_response()
444 }
445 }
446 Err(err) => {
447 let error_msg = format!("Failed to join federation: {err}");
448 if accepts_json {
449 Json(ConnectFedResponse {
450 status: "error".to_string(),
451 federation_info: None,
452 error: Some(error_msg),
453 })
454 .into_response()
455 } else {
456 redirect_error(error_msg).into_response()
457 }
458 }
459 }
460}