fedimint_gateway_ui/
setup.rs

1use axum::Form;
2use axum::extract::{Query, State};
3use axum::response::{Html, IntoResponse, Redirect};
4use bip39::Language;
5use fedimint_gateway_common::SetMnemonicPayload;
6use fedimint_ui_common::{ROOT_ROUTE, UiState, login_layout};
7use maud::{PreEscaped, html};
8use serde::Deserialize;
9
10use crate::{
11    CREATE_WALLET_ROUTE, DashboardQuery, DynGatewayApi, RECOVER_WALLET_ROUTE, redirect_error,
12};
13
14#[derive(Deserialize)]
15pub struct RecoverWalletForm {
16    pub word1: String,
17    pub word2: String,
18    pub word3: String,
19    pub word4: String,
20    pub word5: String,
21    pub word6: String,
22    pub word7: String,
23    pub word8: String,
24    pub word9: String,
25    pub word10: String,
26    pub word11: String,
27    pub word12: String,
28}
29
30/// Renders the main setup page with two options:
31/// - Create New Wallet
32/// - Recover Wallet
33pub async fn setup_view<E>(
34    State(_state): State<UiState<DynGatewayApi<E>>>,
35    Query(msg): Query<DashboardQuery>,
36) -> impl IntoResponse
37where
38    E: std::fmt::Display,
39{
40    let content = html! {
41        @if let Some(error) = msg.ui_error {
42            div class="alert alert-danger mb-3" { (error) }
43        }
44        @if let Some(success) = msg.success {
45            div class="alert alert-success mb-3" { (success) }
46        }
47
48        p class="text-muted mb-4" {
49            "Your gateway needs to be configured before use. "
50            "Choose an option below to set up your wallet."
51        }
52
53        div class="d-grid gap-3" {
54            // Create New Wallet button
55            form action=(CREATE_WALLET_ROUTE) method="post" {
56                button type="submit" class="btn btn-primary btn-lg w-100" {
57                    div class="fw-bold" { "Create New Wallet" }
58                    small class="text-white" {
59                        "Generate a new 12-word recovery phrase"
60                    }
61                }
62            }
63
64            // Recover Wallet button
65            a href=(RECOVER_WALLET_ROUTE) class="btn btn-outline-secondary btn-lg w-100" {
66                div class="fw-bold" { "Recover Wallet" }
67                small class="text-secondary" {
68                    "Use an existing 12-word recovery phrase"
69                }
70            }
71        }
72    };
73
74    Html(login_layout("Setup Gateway", content).into_string())
75}
76
77/// Handler for creating a new wallet (generates new mnemonic)
78pub async fn create_wallet_handler<E>(
79    State(state): State<UiState<DynGatewayApi<E>>>,
80) -> impl IntoResponse
81where
82    E: std::fmt::Display,
83{
84    match state
85        .api
86        .handle_set_mnemonic_msg(SetMnemonicPayload { words: None })
87        .await
88    {
89        Ok(()) => Redirect::to(ROOT_ROUTE).into_response(),
90        Err(err) => redirect_error(format!("Failed to create wallet: {err}")).into_response(),
91    }
92}
93
94/// Renders the recovery form where user can enter their 12 words
95pub async fn recover_wallet_form<E>(
96    State(_state): State<UiState<DynGatewayApi<E>>>,
97    Query(msg): Query<DashboardQuery>,
98) -> impl IntoResponse
99where
100    E: std::fmt::Display,
101{
102    let content = html! {
103        @if let Some(error) = msg.ui_error {
104            div class="alert alert-danger mb-3" { (error) }
105        }
106
107        p class="text-muted mb-3" {
108            "Enter your 12-word recovery phrase to restore your wallet."
109        }
110
111        div class="alert alert-warning mb-3" {
112            strong { "Note: " }
113            "After recovery, you will need to re-join the federations you were previously connected to in order to recover your ecash."
114        }
115
116        form action=(RECOVER_WALLET_ROUTE) method="post" {
117            div class="d-flex flex-column flex-wrap gap-2 mb-3" style="height: 19.5rem;" {
118                @for i in 1..=12 {
119                    div style="width: calc(50% - 0.25rem);" {
120                        div class="input-group" {
121                            span class="input-group-text" style="min-width: 3rem; justify-content: center;" {
122                                (i)
123                            }
124                            input
125                                type="text"
126                                class="form-control"
127                                id=(format!("word{}", i))
128                                name=(format!("word{}", i))
129                                placeholder=(format!("Word {}", i))
130                                required
131                                autocomplete="off"
132                                autocapitalize="none"
133                                spellcheck="false";
134                        }
135                    }
136                }
137            }
138
139            div class="d-flex gap-2" {
140                a href=(ROOT_ROUTE) class="btn btn-outline-secondary" { "Cancel" }
141                button type="submit" class="btn btn-primary flex-grow-1" {
142                    "Recover Wallet"
143                }
144            }
145        }
146
147        // Embed BIP39 word list and validation script
148        script {
149            (PreEscaped(format!(
150                "const BIP39_WORDS = {};",
151                serde_json::to_string(&Language::English.word_list().to_vec()).expect("Failed to serialize BIP39 word list")
152            )))
153            (PreEscaped(r#"
154                const wordSet = new Set(BIP39_WORDS.map(w => w.toLowerCase()));
155
156                document.querySelectorAll('input[id^="word"]').forEach(input => {
157                    input.addEventListener('input', function() {
158                        const value = this.value.trim().toLowerCase();
159                        this.classList.remove('is-valid', 'is-invalid');
160                        if (value.length > 0) {
161                            if (wordSet.has(value)) {
162                                this.classList.add('is-valid');
163                            } else {
164                                this.classList.add('is-invalid');
165                            }
166                        }
167                    });
168                });
169            "#))
170        }
171    };
172
173    Html(login_layout("Recover Wallet", content).into_string())
174}
175
176/// Handler for recovering a wallet with provided mnemonic words
177pub async fn recover_wallet_handler<E>(
178    State(state): State<UiState<DynGatewayApi<E>>>,
179    Form(form): Form<RecoverWalletForm>,
180) -> impl IntoResponse
181where
182    E: std::fmt::Display,
183{
184    // Collect and normalize the 12 words into a single space-separated string
185    let words = [
186        &form.word1,
187        &form.word2,
188        &form.word3,
189        &form.word4,
190        &form.word5,
191        &form.word6,
192        &form.word7,
193        &form.word8,
194        &form.word9,
195        &form.word10,
196        &form.word11,
197        &form.word12,
198    ]
199    .iter()
200    .map(|w| w.trim())
201    .collect::<Vec<_>>()
202    .join(" ");
203
204    match state
205        .api
206        .handle_set_mnemonic_msg(SetMnemonicPayload { words: Some(words) })
207        .await
208    {
209        Ok(()) => Redirect::to(ROOT_ROUTE).into_response(),
210        Err(err) => redirect_error(format!("Failed to recover wallet: {err}")).into_response(),
211    }
212}