prisma_auth 0.1.0

A simple authentication crate supporting both Rocket backend API and Yew frontend authentication components
Documentation
// src/frontend.rs
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())),
    };

    // Add authorization header if token is provided
    if let Some(token) = token {
        request_builder = request_builder.header("Authorization", &format!("Bearer {}", token));
    }

    // Send the request with or without JSON body
    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 {
        // Token might be expired, remove it
        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>,
}

/// Authentication guard component that renders children only if authenticated
#[function_component(AuthGuardComponent)]
pub fn auth_guard(props: &AuthGuardProps) -> Html {
    let is_authenticated_state = use_state(|| is_authenticated());
    
    // Check authentication status on component mount and when storage changes
    {
        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>, // Called with the token on successful login
    #[prop_or_default]
    pub title: Option<String>,
    #[prop_or_default]
    pub subtitle: Option<String>,
}

/// Login component with form handling
#[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 for managing authentication state
#[hook]
pub fn use_auth() -> (bool, Callback<()>) {
    let is_authenticated_state = use_state(|| is_authenticated());
    
    // Refresh authentication state
    let refresh = {
        let is_authenticated_state = is_authenticated_state.clone();
        Callback::from(move |_| {
            is_authenticated_state.set(is_authenticated());
        })
    };
    
    (*is_authenticated_state, refresh)
}