Skip to main content

fedimint_gateway_ui/
connect_fed.rs

1use 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        // Import Modal
73        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                        // File selection view
82                        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                        // Progress view (hidden initially)
105                        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                        // Results view (hidden initially)
130                        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        // JavaScript for modal control and recovery
159        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}