feat(leptos): integrate auth with API + WS auth gating

This commit is contained in:
asepharyana
2026-07-03 18:30:41 +07:00
parent 2520faad47
commit bf36bf55fe
4 changed files with 76 additions and 12 deletions
@@ -1,6 +1,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use shared_types::ui_state::Tab; use shared_types::ui_state::Tab;
use crate::auth::AuthOverlay; use crate::auth::AuthOverlay;
use crate::ws::context::WsContext;
// ── Contexts ──────────────────────────────────────────── // ── Contexts ────────────────────────────────────────────
@@ -33,6 +34,9 @@ pub fn App() -> impl IntoView {
provide_context(auth.clone()); provide_context(auth.clone());
provide_context(ui.clone()); provide_context(ui.clone());
let ws = WsContext::new("ws://localhost:3001");
provide_context(ws.clone());
// Auth check: redirect "live" tab to "messages" if not authenticated // Auth check: redirect "live" tab to "messages" if not authenticated
create_effect(move |_| { create_effect(move |_| {
if !auth.authenticated.get() && ui.active_tab.get() == Tab::Live { if !auth.authenticated.get() && ui.active_tab.get() == Tab::Live {
@@ -40,6 +44,16 @@ pub fn App() -> impl IntoView {
} }
}); });
{
let ws = ws.clone();
let auth = auth.clone();
create_effect(move |_| {
if auth.authenticated.get() {
ws.connect();
}
});
}
view! { view! {
<div data-theme="light"> <div data-theme="light">
// Auth overlay // Auth overlay
+48 -12
View File
@@ -1,24 +1,53 @@
// services/frontend-leptos/frontend/src/auth.rs
use leptos::prelude::*; use leptos::prelude::*;
use leptos::ev::SubmitEvent; use wasm_bindgen_futures::spawn_local;
use crate::app::AuthContext;
use crate::api::auth as auth_api;
#[component] #[component]
pub fn AuthOverlay( pub fn AuthOverlay() -> impl IntoView {
/// Called when password is submitted — parent handles the actual API call let auth = use_context::<AuthContext>().expect("AuthContext not provided");
#[prop(default = ())]
on_submit: (),
) -> impl IntoView {
let (password, set_password) = create_signal(String::new()); let (password, set_password) = create_signal(String::new());
let (error, set_error) = create_signal(Option::<String>::None); let (error, set_error) = create_signal(Option::<String>::None);
let (loading, set_loading) = create_signal(false); let (loading, set_loading) = create_signal(false);
let handle_submit = move |ev: SubmitEvent| { let handle_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
if password.get().is_empty() { let pwd = password.get();
if pwd.is_empty() {
set_error.set(Some("Password diperlukan".to_string())); set_error.set(Some("Password diperlukan".to_string()));
return; return;
} }
// TODO: actual login call (wired in Task 7)
set_loading.set(true); set_loading.set(true);
set_error.set(None);
let auth_clone = auth.clone();
let pwd_clone = pwd.clone();
let set_loading_clone = set_loading.clone();
let set_error_clone = set_error.clone();
spawn_local(async move {
match auth_api::login(&pwd_clone).await {
Ok(true) => {
// Store password in sessionStorage
if let Some(storage) = web_sys::window()
.and_then(|w| w.local_storage().ok())
.flatten()
{
let _ = storage.set_item("admin-password", &pwd_clone);
}
auth_clone.authenticated.set(true);
auth_clone.password.set(pwd_clone);
}
Ok(false) => {
set_error_clone.set(Some("Login gagal — password salah".to_string()));
}
Err(e) => {
set_error_clone.set(Some(format!("Error: {}", e.message)));
}
}
set_loading_clone.set(false);
});
}; };
view! { view! {
@@ -26,9 +55,14 @@ pub fn AuthOverlay(
<div class="modal-content" style="width: 380px;"> <div class="modal-content" style="width: 380px;">
<div class="modal-body" style="text-align: center;"> <div class="modal-body" style="text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;"> <div style="font-size: 3rem; margin-bottom: 1rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div> </div>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">"Akses Dashboard"</h2> <h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">
"Akses Dashboard"
</h2>
<p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1.5rem;"> <p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
"Masukkan password admin untuk melanjutkan" "Masukkan password admin untuk melanjutkan"
</p> </p>
@@ -40,7 +74,9 @@ pub fn AuthOverlay(
prop:value=password prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev)) on:input=move |ev| set_password.set(event_target_value(&ev))
/> />
{move || error.get().map(|e| view! { <p style="color: var(--color-error); font-size: 0.75rem;">{e}</p> })} {move || error.get().map(|e| view! {
<p style="color: var(--color-error); font-size: 0.75rem;">{e}</p>
})}
<button <button
type="submit" type="submit"
class="btn btn-primary w-full btn-lg" class="btn btn-primary w-full btn-lg"
@@ -138,3 +138,10 @@ impl WsContext {
let _ = self.handle.send_binary(data); let _ = self.handle.send_binary(data);
} }
} }
// SAFETY: WsContext uses Rc/RefCell for cheap cloning in a single-threaded WASM
// environment. The leptos reactive system requires provided contexts to be Send+Sync.
#[allow(unsafe_code)]
unsafe impl Send for WsContext {}
#[allow(unsafe_code)]
unsafe impl Sync for WsContext {}
@@ -150,3 +150,10 @@ impl WsHandle {
} }
} }
} }
// SAFETY: WsHandle uses Rc/RefCell for cheap cloning in a single-threaded WASM
// environment. The leptos reactive system requires provided contexts to be Send+Sync.
#[allow(unsafe_code)]
unsafe impl Send for WsHandle {}
#[allow(unsafe_code)]
unsafe impl Sync for WsHandle {}