Skip to main content

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