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}
30
31impl<T> UiState<T> {
32 pub fn new(api: T) -> Self {
33 Self {
34 api,
35 auth_cookie_name: thread_rng().r#gen::<[u8; 4]>().encode_hex(),
36 auth_cookie_value: thread_rng().r#gen::<[u8; 32]>().encode_hex(),
37 }
38 }
39}
40
41pub fn common_head(title: &str) -> Markup {
42 html! {
43 meta charset="utf-8";
44 meta name="viewport" content="width=device-width, initial-scale=1.0";
45 link rel="stylesheet" href="/assets/bootstrap.min.css" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous";
46 link rel="stylesheet" href="/assets/bootstrap-icons.min.css";
47 link rel="stylesheet" type="text/css" href="/assets/style.css";
48 link rel="icon" type="image/png" href="/assets/logo.png";
49
50 script defer src="/assets/htmx.org-2.0.4.min.js" {}
54
55 title { (title) }
56
57 script {
58 (PreEscaped(r#"
59 function copyText(text, btn) {
60 if (navigator.clipboard) {
61 navigator.clipboard.writeText(text).then(function() {
62 showCopied(btn);
63 });
64 } else {
65 var ta = document.createElement('textarea');
66 ta.value = text;
67 ta.style.position = 'fixed';
68 ta.style.opacity = '0';
69 document.body.appendChild(ta);
70 ta.select();
71 document.execCommand('copy');
72 document.body.removeChild(ta);
73 showCopied(btn);
74 }
75 }
76 function showCopied(btn) {
77 if (!btn) return;
78 btn.classList.add('copied');
79 var icon = btn.innerHTML;
80 btn.innerHTML = '<i class="bi bi-check-lg"></i>';
81 setTimeout(function() {
82 btn.innerHTML = icon;
83 btn.classList.remove('copied');
84 }, 2000);
85 }
86 "#))
87 }
88 }
89}
90
91#[derive(Debug, Deserialize)]
92pub struct LoginInput {
93 pub password: String,
94}
95
96pub fn single_card_layout(header: &str, content: Markup) -> Markup {
97 card_layout("col-md-8 col-lg-5 narrow-container", header, content)
98}
99
100fn card_layout(col_class: &str, header: &str, content: Markup) -> Markup {
101 html! {
102 (DOCTYPE)
103 html {
104 head {
105 (common_head("Fedimint"))
106 }
107 body class="d-flex align-items-center min-vh-100" {
108 div class="container" {
109 div class="row justify-content-center" {
110 div class=(col_class) {
111 div class="card" {
112 div class="card-header dashboard-header" { (header) }
113 div class="card-body" {
114 (content)
115 }
116 }
117 }
118 }
119 }
120 (connectivity_widget())
121 script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
122 }
123 }
124 }
125}
126
127pub fn copiable_text(text: &str) -> Markup {
130 html! {
131 div class="input-group" {
132 input type="text" class="form-control form-control-sm font-monospace"
133 value=(text) readonly;
134 button type="button" class="btn btn-outline-secondary"
135 onclick=(format!("copyText('{}', this)", text)) {
136 i class="bi bi-clipboard" {}
137 }
138 }
139 }
140}
141
142pub fn login_form(error: Option<&str>) -> Markup {
143 html! {
144 form id="login-form" hx-post=(LOGIN_ROUTE) hx-target="#login-form" hx-swap="outerHTML" {
145 div class="form-group mb-3" {
146 input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required autofocus;
147 }
148 @if let Some(error) = error {
149 div class="alert alert-danger mb-3" { (error) }
150 }
151 button type="submit" class="btn btn-primary w-100 py-2" { "Continue" }
152 }
153 }
154}
155
156pub fn login_submit_response(
157 auth: ApiAuth,
158 auth_cookie_name: String,
159 auth_cookie_value: String,
160 jar: CookieJar,
161 input: LoginInput,
162) -> impl IntoResponse {
163 if auth.verify(&input.password) {
164 let mut cookie = Cookie::new(auth_cookie_name, auth_cookie_value);
165
166 cookie.set_http_only(true);
167 cookie.set_same_site(Some(SameSite::Lax));
168
169 return (jar.add(cookie), [("HX-Redirect", "/")]).into_response();
170 }
171
172 Html(login_form(Some("The password is invalid")).into_string()).into_response()
173}
174
175pub fn dashboard_layout(content: Markup, version: &str) -> Markup {
176 html! {
177 (DOCTYPE)
178 html {
179 head {
180 (common_head("Fedimint"))
181 }
182 body {
183 div class="container" {
184 (content)
185
186 div class="text-center mt-4 mb-3" {
187 span class="text-muted" { "Version " (version) }
188 }
189 }
190 (connectivity_widget())
191 script src="/assets/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
192 }
193 }
194 }
195}
196
197pub fn connectivity_widget() -> Markup {
199 html! {
200 div
201 style="position: fixed; bottom: 1rem; right: 1rem; z-index: 1050;"
202 hx-get=(CONNECTIVITY_CHECK_ROUTE)
203 hx-trigger="load, every 30s"
204 hx-swap="innerHTML"
205 {}
206 }
207}
208
209async fn check_tcp_connect(addr: SocketAddr) -> bool {
210 timeout(Duration::from_secs(3), TcpStream::connect(addr))
211 .await
212 .is_ok_and(|r| r.is_ok())
213}
214
215pub async fn connectivity_check_handler<Api: Send + Sync + 'static>(
220 State(state): State<UiState<Api>>,
221 jar: CookieJar,
222) -> Html<String> {
223 let authenticated = jar
225 .get(&state.auth_cookie_name)
226 .is_some_and(|c| c.value() == state.auth_cookie_value);
227
228 if !authenticated {
229 return Html(String::new());
230 }
231
232 let check_1 = check_tcp_connect(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443));
233 let check_2 = check_tcp_connect(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)), 53));
234
235 let (r1, r2) = tokio::join!(check_1, check_2);
236 let is_connected = r1 || r2;
237
238 let markup = if is_connected {
239 html! {
240 span class="badge bg-success" style="font-size: 0.75rem;" {
241 "Internet connection OK"
242 }
243 }
244 } else {
245 html! {
246 span class="badge bg-danger" style="font-size: 0.75rem;" {
247 "Internet connection unavailable"
248 }
249 }
250 };
251
252 Html(markup.into_string())
253}