Added auth
This commit is contained in:
@@ -55,6 +55,49 @@ async fn delete(url: &str) -> Result<(), ApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
pub async fn check_setup_required() -> Result<SetupRequired, ApiError> {
|
||||
get_json(&format!("{BASE}/auth/setup-required")).await
|
||||
}
|
||||
|
||||
pub async fn setup(username: &str, password: &str) -> Result<UserInfo, ApiError> {
|
||||
let body = serde_json::json!({"username": username, "password": password}).to_string();
|
||||
post_json(&format!("{BASE}/auth/setup"), &body).await
|
||||
}
|
||||
|
||||
pub async fn login(username: &str, password: &str) -> Result<UserInfo, ApiError> {
|
||||
let body = serde_json::json!({"username": username, "password": password}).to_string();
|
||||
post_json(&format!("{BASE}/auth/login"), &body).await
|
||||
}
|
||||
|
||||
pub async fn logout() -> Result<(), ApiError> {
|
||||
let resp = Request::post(&format!("{BASE}/auth/logout"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError(e.to_string()))?;
|
||||
if !resp.ok() {
|
||||
return Err(ApiError(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_me() -> Result<UserInfo, ApiError> {
|
||||
get_json(&format!("{BASE}/auth/me")).await
|
||||
}
|
||||
|
||||
pub async fn list_users() -> Result<Vec<UserInfo>, ApiError> {
|
||||
get_json(&format!("{BASE}/auth/users")).await
|
||||
}
|
||||
|
||||
pub async fn create_user(username: &str, password: &str) -> Result<UserInfo, ApiError> {
|
||||
let body = serde_json::json!({"username": username, "password": password}).to_string();
|
||||
post_json(&format!("{BASE}/auth/users"), &body).await
|
||||
}
|
||||
|
||||
pub async fn delete_user(id: i32) -> Result<(), ApiError> {
|
||||
delete(&format!("{BASE}/auth/users/{id}")).await
|
||||
}
|
||||
|
||||
// --- Status ---
|
||||
pub async fn get_status() -> Result<Status, ApiError> {
|
||||
get_json(&format!("{BASE}/status")).await
|
||||
|
||||
@@ -3,8 +3,15 @@ use yew_router::prelude::*;
|
||||
|
||||
use crate::pages::Route;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub on_logout: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(Navbar)]
|
||||
pub fn navbar() -> Html {
|
||||
pub fn navbar(props: &Props) -> Html {
|
||||
let route = use_route::<Route>();
|
||||
|
||||
let link = |to: Route, label: &str| {
|
||||
@@ -15,6 +22,14 @@ pub fn navbar() -> Html {
|
||||
}
|
||||
};
|
||||
|
||||
let on_logout = {
|
||||
let cb = props.on_logout.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
cb.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="sidebar">
|
||||
<h1>{ "Shanty" }</h1>
|
||||
@@ -23,8 +38,14 @@ pub fn navbar() -> Html {
|
||||
{ link(Route::Search, "Search") }
|
||||
{ link(Route::Library, "Library") }
|
||||
{ link(Route::Downloads, "Downloads") }
|
||||
{ link(Route::Settings, "Settings") }
|
||||
if props.role == "admin" {
|
||||
{ link(Route::Settings, "Settings") }
|
||||
}
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<span class="text-sm text-muted">{ &props.username }</span>
|
||||
<a href="#" class="text-sm" onclick={on_logout}>{ "Logout" }</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,86 @@ use yew_router::prelude::*;
|
||||
|
||||
use components::navbar::Navbar;
|
||||
use pages::{switch, Route};
|
||||
use types::UserInfo;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum AuthState {
|
||||
Loading,
|
||||
NeedsSetup,
|
||||
NeedsLogin,
|
||||
Authenticated(UserInfo),
|
||||
}
|
||||
|
||||
#[function_component(App)]
|
||||
fn app() -> Html {
|
||||
html! {
|
||||
<BrowserRouter>
|
||||
<div class="app">
|
||||
<Navbar />
|
||||
<div class="main-content">
|
||||
<Switch<Route> render={switch} />
|
||||
</div>
|
||||
let auth = use_state(|| AuthState::Loading);
|
||||
|
||||
// Check auth state on mount
|
||||
{
|
||||
let auth = auth.clone();
|
||||
use_effect_with((), move |_| {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::get_me().await {
|
||||
Ok(user) => auth.set(AuthState::Authenticated(user)),
|
||||
Err(_) => {
|
||||
// Not logged in — check if setup is needed
|
||||
match api::check_setup_required().await {
|
||||
Ok(sr) if sr.required => auth.set(AuthState::NeedsSetup),
|
||||
_ => auth.set(AuthState::NeedsLogin),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let on_auth_success = {
|
||||
let auth = auth.clone();
|
||||
Callback::from(move |_: ()| {
|
||||
let auth = auth.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Ok(user) = api::get_me().await {
|
||||
auth.set(AuthState::Authenticated(user));
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
match &*auth {
|
||||
AuthState::Loading => html! {
|
||||
<div class="auth-page">
|
||||
<p class="loading">{ "Loading..." }</p>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
},
|
||||
AuthState::NeedsSetup => html! {
|
||||
<pages::setup::SetupPage on_setup={on_auth_success.clone()} />
|
||||
},
|
||||
AuthState::NeedsLogin => html! {
|
||||
<pages::login::LoginPage on_login={on_auth_success.clone()} />
|
||||
},
|
||||
AuthState::Authenticated(user) => {
|
||||
let user = user.clone();
|
||||
let on_logout = {
|
||||
let auth = auth.clone();
|
||||
Callback::from(move |_: ()| {
|
||||
let auth = auth.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = api::logout().await;
|
||||
auth.set(AuthState::NeedsLogin);
|
||||
});
|
||||
})
|
||||
};
|
||||
html! {
|
||||
<BrowserRouter>
|
||||
<div class="app">
|
||||
<Navbar username={user.username.clone()} role={user.role.clone()} on_logout={on_logout} />
|
||||
<div class="main-content">
|
||||
<Switch<Route> render={switch} />
|
||||
</div>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
frontend/src/pages/login.rs
Normal file
71
frontend/src/pages/login.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub on_login: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(LoginPage)]
|
||||
pub fn login_page(props: &Props) -> Html {
|
||||
let username = use_state(String::new);
|
||||
let password = use_state(String::new);
|
||||
let error = use_state(|| None::<String>);
|
||||
|
||||
let on_submit = {
|
||||
let username = username.clone();
|
||||
let password = password.clone();
|
||||
let error = error.clone();
|
||||
let on_login = props.on_login.clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
let u = (*username).clone();
|
||||
let p = (*password).clone();
|
||||
let error = error.clone();
|
||||
let on_login = on_login.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::login(&u, &p).await {
|
||||
Ok(_) => on_login.emit(()),
|
||||
Err(e) => error.set(Some(e.0)),
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h1>{ "Shanty" }</h1>
|
||||
<p class="text-muted">{ "Sign in to continue" }</p>
|
||||
|
||||
if let Some(ref err) = *error {
|
||||
<div class="error">{ err }</div>
|
||||
}
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label>{ "Username" }</label>
|
||||
<input type="text" autocomplete="username" value={(*username).clone()}
|
||||
oninput={let u = username.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
u.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Password" }</label>
|
||||
<input type="password" autocomplete="current-password" value={(*password).clone()}
|
||||
oninput={let p = password.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
p.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1rem;">
|
||||
{ "Sign In" }
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ pub mod artist;
|
||||
pub mod dashboard;
|
||||
pub mod downloads;
|
||||
pub mod library;
|
||||
pub mod login;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
pub mod setup;
|
||||
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
92
frontend/src/pages/setup.rs
Normal file
92
frontend/src/pages/setup.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct Props {
|
||||
pub on_setup: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(SetupPage)]
|
||||
pub fn setup_page(props: &Props) -> Html {
|
||||
let username = use_state(String::new);
|
||||
let password = use_state(String::new);
|
||||
let confirm = use_state(String::new);
|
||||
let error = use_state(|| None::<String>);
|
||||
|
||||
let on_submit = {
|
||||
let username = username.clone();
|
||||
let password = password.clone();
|
||||
let confirm = confirm.clone();
|
||||
let error = error.clone();
|
||||
let on_setup = props.on_setup.clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
let u = (*username).clone();
|
||||
let p = (*password).clone();
|
||||
let c = (*confirm).clone();
|
||||
let error = error.clone();
|
||||
let on_setup = on_setup.clone();
|
||||
|
||||
if p != c {
|
||||
error.set(Some("Passwords do not match".into()));
|
||||
return;
|
||||
}
|
||||
if p.len() < 4 {
|
||||
error.set(Some("Password must be at least 4 characters".into()));
|
||||
return;
|
||||
}
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match api::setup(&u, &p).await {
|
||||
Ok(_) => on_setup.emit(()),
|
||||
Err(e) => error.set(Some(e.0)),
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h1>{ "Shanty" }</h1>
|
||||
<p class="text-muted">{ "Create your admin account to get started" }</p>
|
||||
|
||||
if let Some(ref err) = *error {
|
||||
<div class="error">{ err }</div>
|
||||
}
|
||||
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label>{ "Username" }</label>
|
||||
<input type="text" autocomplete="username" value={(*username).clone()}
|
||||
oninput={let u = username.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
u.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Password" }</label>
|
||||
<input type="password" autocomplete="new-password" value={(*password).clone()}
|
||||
oninput={let p = password.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
p.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{ "Confirm Password" }</label>
|
||||
<input type="password" autocomplete="new-password" value={(*confirm).clone()}
|
||||
oninput={let c = confirm.clone(); Callback::from(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
c.set(input.value());
|
||||
})} />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 1rem;">
|
||||
{ "Create Admin Account" }
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct SetupRequired {
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
// --- Library entities ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
|
||||
@@ -38,6 +38,8 @@ a:hover { color: var(--accent-hover); }
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar h1 { font-size: 1.5rem; margin-bottom: 2rem; color: var(--accent); }
|
||||
.sidebar nav a {
|
||||
@@ -177,3 +179,32 @@ table.tasks-table td { overflow: hidden; text-overflow: ellipsis; }
|
||||
.loading { color: var(--text-muted); font-style: italic; }
|
||||
.error { color: var(--danger); }
|
||||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
|
||||
/* Auth pages */
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
.auth-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.auth-card h1 { font-size: 1.8rem; color: var(--accent); margin-bottom: 0.25rem; }
|
||||
.auth-card p { margin-bottom: 1.5rem; }
|
||||
|
||||
/* Sidebar user section */
|
||||
.sidebar-user {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user