fedimint_ui_common/
lib.rs1pub mod assets;
2pub mod auth;
3
4use std::net::{IpAddr, Ipv4Addr, SocketAddr};
5use std::time::Duration;
6
7use axum::extract::State;
8use axum::response::{Html, IntoResponse};
9use axum_extra::extract::CookieJar;
10use axum_extra::extract::cookie::{Cookie, SameSite};
11use fedimint_core::hex::ToHex;
12use fedimint_core::module::ApiAuth;
13use fedimint_core::secp256k1::rand::{Rng, thread_rng};
14use maud::{DOCTYPE, Markup, PreEscaped, html};
15use serde::Deserialize;
16use tokio::net::TcpStream;
17use tokio::time::timeout;
18
19pub const ROOT_ROUTE: &str = "/";
20pub const LOGIN_ROUTE: &str = "/login";
21pub const CONNECTIVITY_CHECK_ROUTE: &str = "/ui/connectivity-check";
22
23#[derive(Clone)]
25pub struct UiState<T> {
26 pub api: T,
27 pub auth_cookie_name: String,
28 pub auth_cookie_value: String,
29 pub requires_auth: bool,
33}
34
35impl<T> UiState<T> {
36 pub fn new(api: T, requires_auth: bool) -> Self {
37 Self {
38 api,
39 auth_cookie_name: thread_rng().r#gen::<[u8; 4]>().encode_hex(),
40 auth_cookie_value: thread_rng().r#gen::<[u8; 32]>().encode_hex(),
41 requires_auth,
42 }
43 }
44}
45
46pub fn common_head(title: &str) -> Markup {
47 html! {
48 meta charset="utf-8";
49 meta name="viewport" content="width=device-width, initial-scale=1.0";
50 link rel="stylesheet" href="/assets/bootstrap.min.css" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous";
51 link rel="stylesheet" href="/assets/bootstrap-icons.min.css";
52 link rel="stylesheet" type="text/css" href="/assets/style.css";
53 link rel="icon" type="image/png" href="/assets/logo.png";
54
55 script defer src="/assets/htmx.org-2.0.4.min.js" {}
59
60 title { (title) }
61
62 script {
63 (PreEscaped(r#"
64 function copyText(text, btn) {
65 if (navigator.clipboard) {
66 navigator.clipboard.writeText(text).then(function() {
67 showCopied(btn);
68 });
69 } else {
70 var ta = document.createElement('textarea');
71 ta.value = text;
72 ta.style.position = 'fixed';
73 ta.style.opacity = '0';
74 document.body.appendChild(ta);
75 ta.select();
76 document.execCommand('copy');
77 document.body.removeChild(ta);
78 showCopied(btn);
79 }
80 }
81 function showCopied(btn) {
82 if (!btn) return;
83 btn.classList.add('copied');
84 var icon = btn.innerHTML;
85 btn.innerHTML = '<i class="bi bi-check-lg"></i>';
86 setTimeout(function() {
87 btn.innerHTML = icon;
88 btn.classList.remove('copied');
89 }, 2000);
90 }
91 "#))
92 }
93 }
94}
95
96#[derive(Debug, Deserialize)]
97pub struct LoginInput {
98 pub password: String,
99}
100
101pub fn single_card_layout(header: &str, content: Markup) -> Markup {
102 card_layout("col-md-8 col-lg-5 narrow-container", header, content, None)
103}
104
105pub fn single_card_layout_with_version(
108 header: &str,
109 content: Markup,
110 version: &str,
111 version_hash: Option<&str>,
112) -> Markup {
113 card_layout(
114 "col-md-8 col-lg-5 narrow-container",
115 header,
116 content,
117 Some(version_footer(version, version_hash)),
118 )
119}
120
121fn card_layout(col_class: &str, header: &str, content: Markup, footer: Option<Markup>) -> Markup {
122 html! {
123 (DOCTYPE)
124 html {
125 head {
126 (common_head("Fedimint"))
127 }
128 body class="d-flex flex-column min-vh-100" {
129 div class="container my-auto" {
130 div class="row justify-content-center" {
131 div class=(col_class) {
132 div class="card" {
133 div class="card-header dashboard-header" { (header) }
134 div class="card-body" {
135 (content)
136 }
137 }
138 }
139 }
140 }
141 @if let Some(footer) = footer {
142 (footer)
143 }
144 (connectivity_widget())
145 script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
146 }
147 }
148 }
149}
150
151pub fn copiable_text(text: &str) -> Markup {
154 html! {
155 div class="input-group" {
156 input type="text" class="form-control form-control-sm font-monospace"
157 value=(text) readonly;
158 button type="button" class="btn btn-outline-secondary"
159 onclick=(format!("copyText('{}', this)", text)) {
160 i class="bi bi-clipboard" {}
161 }
162 }
163 }
164}
165
166pub fn login_form(error: Option<&str>) -> Markup {
167 html! {
168 form id="login-form" hx-post=(LOGIN_ROUTE) hx-target="#login-form" hx-swap="outerHTML" {
169 div class="form-group mb-3" {
170 input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required autofocus;
171 }
172 @if let Some(error) = error {
173 div class="alert alert-danger mb-3" { (error) }
174 }
175 button type="submit" class="btn btn-primary w-100 py-2" { "Continue" }
176 }
177 }
178}
179
180pub fn login_submit_response(
181 auth: ApiAuth,
182 auth_cookie_name: String,
183 auth_cookie_value: String,
184 jar: CookieJar,
185 input: LoginInput,
186) -> impl IntoResponse {
187 if auth.verify(&input.password) {
188 let mut cookie = Cookie::new(auth_cookie_name, auth_cookie_value);
189
190 cookie.set_http_only(true);
191 cookie.set_same_site(Some(SameSite::Lax));
192
193 return (jar.add(cookie), [("HX-Redirect", "/")]).into_response();
194 }
195
196 Html(login_form(Some("The password is invalid")).into_string()).into_response()
197}
198
199pub fn dashboard_layout(content: Markup, version: &str, version_hash: Option<&str>) -> Markup {
200 html! {
201 (DOCTYPE)
202 html {
203 head {
204 (common_head("Fedimint"))
205 }
206 body {
207 div class="container" {
208 (content)
209 }
210 (version_footer(version, version_hash))
211 (connectivity_widget())
212 script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
213 }
214 }
215 }
216}
217
218pub fn version_footer(version: &str, version_hash: Option<&str>) -> Markup {
221 html! {
222 div class="text-center mt-4 mb-3" {
223 span class="text-muted" { "Version " (version) }
224 @if let Some(hash) = version_hash {
225 @let short_hash: String = hash.chars().take(7).collect();
226 span class="text-muted ms-2 font-monospace" style="font-size: 0.85em;" {
227 "(" (short_hash) ")"
228 }
229 }
230 }
231 }
232}
233
234pub fn connectivity_widget() -> Markup {
236 html! {
237 div
238 style="position: fixed; bottom: 1rem; right: 1rem; z-index: 1050;"
239 hx-get=(CONNECTIVITY_CHECK_ROUTE)
240 hx-trigger="load, every 30s"
241 hx-swap="innerHTML"
242 {}
243 }
244}
245
246async fn check_tcp_connect(addr: SocketAddr) -> bool {
247 timeout(Duration::from_secs(3), TcpStream::connect(addr))
248 .await
249 .is_ok_and(|r| r.is_ok())
250}
251
252pub async fn connectivity_check_handler<Api: Send + Sync + 'static>(
257 State(state): State<UiState<Api>>,
258 jar: CookieJar,
259) -> Html<String> {
260 let authenticated = !state.requires_auth
263 || jar
264 .get(&state.auth_cookie_name)
265 .is_some_and(|c| c.value() == state.auth_cookie_value);
266
267 if !authenticated {
268 return Html(String::new());
269 }
270
271 let check_1 = check_tcp_connect(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443));
272 let check_2 = check_tcp_connect(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 53));
273
274 let (r1, r2) = tokio::join!(check_1, check_2);
275 let is_connected = r1 || r2;
276
277 let markup = if is_connected {
278 html! {
279 span class="badge bg-success" style="font-size: 0.75rem;" {
280 "Internet connection OK"
281 }
282 }
283 } else {
284 html! {
285 span class="badge bg-danger" style="font-size: 0.75rem;" {
286 "Internet connection unavailable"
287 }
288 }
289 };
290
291 Html(markup.into_string())
292}