fedimint_server_ui/
lib.rs

1pub mod audit;
2pub mod dashboard;
3pub mod invite_code;
4pub mod latency;
5pub mod lnv2;
6pub mod setup;
7pub mod wallet;
8
9use axum::response::{Html, IntoResponse, Redirect};
10use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
11use fedimint_core::hex::ToHex;
12use fedimint_core::module::ApiAuth;
13use fedimint_core::secp256k1::rand::{Rng, thread_rng};
14use maud::{DOCTYPE, Markup, html};
15use serde::Deserialize;
16
17#[derive(Debug, Deserialize)]
18pub(crate) struct LoginInput {
19    pub password: String,
20}
21
22/// Generic state for both setup and dashboard UIs
23#[derive(Clone)]
24pub struct AuthState<T> {
25    pub(crate) api: T,
26    pub(crate) auth_cookie_name: String,
27    pub(crate) auth_cookie_value: String,
28}
29
30impl<T> AuthState<T> {
31    pub fn new(api: T) -> Self {
32        Self {
33            api,
34            auth_cookie_name: thread_rng().r#gen::<[u8; 4]>().encode_hex(),
35            auth_cookie_value: thread_rng().r#gen::<[u8; 32]>().encode_hex(),
36        }
37    }
38}
39
40// Common CSS styling shared by all layouts
41pub fn common_styles() -> &'static str {
42    r#"
43    body {
44        background-color: #f8f9fa;
45        padding-top: 2rem;
46        padding-bottom: 2rem;
47    }
48    
49    .header-title {
50        color: #0d6efd;
51        margin-bottom: 2rem;
52    }
53    
54    .card {
55        border: none;
56        box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
57        border-radius: 0.5rem;
58        margin-bottom: 1rem;
59    }
60    
61    .card-body {
62        padding: 1.25rem;
63    }
64    
65    .card-header {
66        background-color: #fff;
67        border-bottom: 1px solid rgba(0, 0, 0, 0.125);
68        padding: 1rem 1.25rem;
69    }
70    
71    /* Form elements */
72    .form-control {
73        padding: 0.75rem;
74    }
75    
76    .form-group {
77        margin-bottom: 1rem;
78    }
79    
80    .field-description {
81        font-size: 0.875rem;
82        color: #6c757d;
83        margin-top: 0.25rem;
84    }
85    
86    .button-container {
87        margin-top: 2rem;
88        text-align: center;
89    }
90    
91    /* Alert and status messages */
92    .error-message {
93        color: #dc3545;
94        margin-top: 1rem;
95        font-weight: 500;
96    }
97    
98    .alert-info {
99        background-color: #e8f4f8;
100        border-color: #bee5eb;
101    }
102    
103    /* Button styling */
104    .setup-btn {
105        width: auto;
106        min-width: 200px;
107        max-width: 300px;
108        padding: 0.5rem 1.5rem;
109        margin: 0 auto;
110    }
111    
112    /* Responsive adjustments */
113    @media (min-width: 992px) {
114        .narrow-container {
115            max-width: 500px;
116        }
117    }
118    
119    @media (max-width: 768px) {
120        .container {
121            padding-left: 15px;
122            padding-right: 15px;
123        }
124        
125        .card-body {
126            padding: 1rem;
127        }
128    }
129    "#
130}
131
132pub(crate) fn login_layout(title: &str, content: Markup) -> Markup {
133    html! {
134        (DOCTYPE)
135        html {
136            head {
137                meta charset="utf-8";
138                meta name="viewport" content="width=device-width, initial-scale=1.0";
139                title { (title) }
140                link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous";
141                style {
142                    (common_styles())
143                }
144            }
145            body {
146                div class="container" {
147                    div class="row justify-content-center" {
148                        div class="col-md-8 col-lg-5 narrow-container" {
149                            header class="text-center" {
150                                h1 class="header-title" { "Fedimint Guardian UI" }
151                            }
152
153                            div class="card" {
154                                div class="card-body" {
155                                    (content)
156                                }
157                            }
158                        }
159                    }
160                }
161                script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
162            }
163        }
164    }
165}
166
167pub(crate) fn login_form_response() -> impl IntoResponse {
168    let content = html! {
169        form method="post" action="/login" {
170            div class="form-group mb-4" {
171                input type="password" class="form-control" id="password" name="password" placeholder="Your password" required;
172            }
173            div class="button-container" {
174                button type="submit" class="btn btn-primary setup-btn" { "Log In" }
175            }
176        }
177    };
178
179    Html(login_layout("Fedimint Guardian Login", content).into_string()).into_response()
180}
181
182pub(crate) fn login_submit_response(
183    auth: ApiAuth,
184    auth_cookie_name: String,
185    auth_cookie_value: String,
186    jar: CookieJar,
187    input: LoginInput,
188) -> impl IntoResponse {
189    if auth.0 == input.password {
190        let mut cookie = Cookie::new(auth_cookie_name, auth_cookie_value);
191
192        cookie.set_http_only(true);
193        cookie.set_same_site(Some(SameSite::Lax));
194
195        return (jar.add(cookie), Redirect::to("/")).into_response();
196    }
197
198    let content = html! {
199        div class="alert alert-danger" { "The password is invalid" }
200        div class="button-container" {
201            a href="/login" class="btn btn-primary setup-btn" { "Return to Login" }
202        }
203    };
204
205    Html(login_layout("Login Failed", content).into_string()).into_response()
206}
207
208pub(crate) async fn check_auth(
209    auth_cookie_name: &str,
210    auth_cookie_value: &str,
211    jar: &CookieJar,
212) -> bool {
213    match jar.get(auth_cookie_name) {
214        Some(cookie) => cookie.value() == auth_cookie_value,
215        None => false,
216    }
217}