use yew::prelude::*;
use web_sys::HtmlInputElement;
use gloo::storage::{LocalStorage, Storage};
use gloo_net::http::Request;
use wasm_bindgen_futures::spawn_local;
use serde_json::Value;
use crate::{TokenResponse, TOKEN_KEY, AuthError};
pub fn get_stored_token() -> Option<String> {
LocalStorage::get::<String>(TOKEN_KEY).ok()
}
pub fn is_authenticated() -> bool {
get_stored_token().is_some()
}
pub fn store_token(token: &str) -> Result<(), AuthError> {
LocalStorage::set(TOKEN_KEY, token)
.map_err(|e| AuthError::StorageError(format!("Failed to store token: {:?}", e)))
}
pub fn logout() -> Result<(), AuthError> {
LocalStorage::delete(TOKEN_KEY);
Ok(())
}
pub async fn make_authenticated_request(
method: &str,
endpoint: &str,
body: Option<Value>,
base_url: &str,
) -> Result<String, AuthError> {
let token = get_stored_token().ok_or(AuthError::TokenNotFound)?;
make_request_with_token(method, endpoint, body, Some(&token), base_url).await
}
pub async fn make_request_with_token(
method: &str,
endpoint: &str,
body: Option<Value>,
token: Option<&str>,
base_url: &str,
) -> Result<String, AuthError> {
let url = format!("{}{}", base_url, endpoint);
let mut request_builder = match method {
"GET" => Request::get(&url),
"POST" => Request::post(&url),
"PUT" => Request::put(&url),
"DELETE" => Request::delete(&url),
_ => return Err(AuthError::NetworkError("Unsupported HTTP method".to_string())),
};
if let Some(token) = token {
request_builder = request_builder.header("Authorization", &format!("Bearer {}", token));
}
let response = if let Some(body) = body {
request_builder
.header("Content-Type", "application/json")
.json(&body)
.map_err(|e| AuthError::SerializationError(format!("Failed to serialize JSON: {}", e)))?
.send()
.await
} else {
request_builder.send().await
};
let response = response.map_err(|e| AuthError::NetworkError(format!("Request failed: {}", e)))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|e| AuthError::NetworkError(format!("Failed to read response: {}", e)))?;
if status >= 200 && status < 300 {
Ok(text)
} else if status == 401 {
let _ = logout();
Err(AuthError::TokenExpired)
} else {
Err(AuthError::NetworkError(format!("HTTP {}: {}", status, text)))
}
}
#[derive(Properties, PartialEq)]
pub struct AuthGuardProps {
pub children: Children,
#[prop_or_default]
pub fallback: Option<Html>,
}
#[function_component(AuthGuardComponent)]
pub fn auth_guard(props: &AuthGuardProps) -> Html {
let is_authenticated_state = use_state(|| is_authenticated());
{
let is_authenticated_state = is_authenticated_state.clone();
use_effect_with((), move |_| {
is_authenticated_state.set(is_authenticated());
|| ()
});
}
if *is_authenticated_state {
html! {
<div class="authenticated-content">
{ for props.children.iter() }
</div>
}
} else {
props.fallback.clone().unwrap_or_else(|| {
html! {
<div class="auth-notice">
<div class="auth-notice-card">
<h2>{"🔒 Authentication Required"}</h2>
<p>{"You need to be logged in to access this page."}</p>
</div>
</div>
}
})
}
}
#[derive(Properties, PartialEq)]
pub struct LoginProps {
pub api_base_url: String,
pub on_success: Callback<String>, #[prop_or_default]
pub title: Option<String>,
#[prop_or_default]
pub subtitle: Option<String>,
}
#[function_component(LoginComponent)]
pub fn login(props: &LoginProps) -> Html {
let input_password = use_state(|| String::new());
let error_message = use_state(|| None::<String>);
let is_loading = use_state(|| false);
let oninput = {
let password = input_password.clone();
Callback::from(move |e: InputEvent| {
let value = e.target_unchecked_into::<HtmlInputElement>().value();
password.set(value);
})
};
let onclick = {
let password = (*input_password).clone();
let error_message = error_message.clone();
let is_loading = is_loading.clone();
let on_success = props.on_success.clone();
let api_base_url = props.api_base_url.clone();
Callback::from(move |_| {
let password = password.clone();
let error_message = error_message.clone();
let is_loading = is_loading.clone();
let on_success = on_success.clone();
let api_base_url = api_base_url.clone();
spawn_local(async move {
is_loading.set(true);
error_message.set(None);
let body = serde_json::json!({"password": password});
match make_request_with_token("POST", "/login", Some(body), None, &api_base_url).await {
Ok(response) => {
match serde_json::from_str::<TokenResponse>(&response) {
Ok(token_response) => {
if let Err(e) = store_token(&token_response.token) {
error_message.set(Some(format!("Failed to store token: {}", e)));
} else {
on_success.emit(token_response.token);
}
}
Err(_) => {
error_message.set(Some("Invalid response format".to_string()));
}
}
}
Err(err) => {
error_message.set(Some(err.to_string()));
}
}
is_loading.set(false);
});
})
};
let title = props.title.clone().unwrap_or_else(|| "Login".to_string());
let subtitle = props.subtitle.clone().unwrap_or_else(|| "Please enter your credentials".to_string());
html! {
<div class="login-container">
<section class="login-card">
<h1 class="text-center mb-4">{ title }</h1>
<p class="text-center mb-4" style="color: #b0b0b0;">{ subtitle }</p>
{ if let Some(error) = (*error_message).as_ref() {
html! {
<div class="response-error response-header mb-2 text-center">
{ error }
</div>
}
} else {
html! {}
}}
<input
class="input form-input"
type="password"
placeholder="Admin Password"
value={(*input_password).clone()}
{oninput}
disabled={*is_loading}
/>
<button
class="btn btn-primary form-input"
onclick={onclick}
disabled={*is_loading || input_password.is_empty()}
>
{ if *is_loading { "Logging in..." } else { "Enter" } }
</button>
</section>
</div>
}
}
#[hook]
pub fn use_auth() -> (bool, Callback<()>) {
let is_authenticated_state = use_state(|| is_authenticated());
let refresh = {
let is_authenticated_state = is_authenticated_state.clone();
Callback::from(move |_| {
is_authenticated_state.set(is_authenticated());
})
};
(*is_authenticated_state, refresh)
}