Compare commits

..

7 Commits

Author SHA1 Message Date
spinline
08f2f540fe Fix unused import and dead code warnings
All checks were successful
Build MIPS Binary / build (push) Successful in 4m6s
2026-02-07 15:20:23 +03:00
spinline
7361421641 Fix middleware signature: Specify Request<Body> explicitly
All checks were successful
Build MIPS Binary / build (push) Successful in 4m7s
2026-02-07 15:13:05 +03:00
spinline
d6ecc08398 Upgrade axum-extra to 0.10 for Axum 0.8 compatibility
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-07 15:11:08 +03:00
spinline
472bac85f3 Fix compilation errors: Resolve utoipa derive issues, add time dependency, and correct Axum middleware signature
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-07 15:08:53 +03:00
spinline
bb3ec14a75 Fix compilation errors: Add missing dependencies, fix module visibility, and update Axum middleware types
Some checks failed
Build MIPS Binary / build (push) Failing after 3m27s
2026-02-07 14:58:35 +03:00
spinline
d53d661ad1 Implement authentication system with SQLite: Add login/setup pages, auth middleware, and database integration
Some checks failed
Build MIPS Binary / build (push) Failing after 3m42s
2026-02-07 14:43:25 +03:00
spinline
92720c15b3 Fix mobile dropdown interaction: Revert to pointerdown with stop_propagation and use overlay for reliable closing
All checks were successful
Build MIPS Binary / build (push) Successful in 3m57s
2026-02-07 14:14:37 +03:00
14 changed files with 1702 additions and 298 deletions

690
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,3 +33,9 @@ utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
base64 = "0.22"
openssl = { version = "0.10", features = ["vendored"], optional = true }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
bcrypt = "0.17.0"
axum-extra = { version = "0.10", features = ["cookie"] }
rand = "0.8"
anyhow = "1.0.101"
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }

106
backend/src/db.rs Normal file
View File

@@ -0,0 +1,106 @@
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row};
use std::time::Duration;
use anyhow::Result;
#[derive(Clone)]
pub struct Db {
pool: Pool<Sqlite>,
}
impl Db {
pub async fn new(db_url: &str) -> Result<Self> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(db_url)
.await?;
let db = Self { pool };
db.init().await?;
Ok(db)
}
async fn init(&self) -> Result<()> {
// Create users table
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
)
.execute(&self.pool)
.await?;
// Create sessions table
sqlx::query(
"CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)",
)
.execute(&self.pool)
.await?;
Ok(())
}
// --- User Operations ---
pub async fn create_user(&self, username: &str, password_hash: &str) -> Result<()> {
sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
.bind(username)
.bind(password_hash)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_user_by_username(&self, username: &str) -> Result<Option<(i64, String)>> {
let row = sqlx::query("SELECT id, password_hash FROM users WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.get(0), r.get(1))))
}
pub async fn has_users(&self) -> Result<bool> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await?;
Ok(row.0 > 0)
}
// --- Session Operations ---
pub async fn create_session(&self, user_id: i64, token: &str, expires_at: i64) -> Result<()> {
sqlx::query("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime(?, 'unixepoch'))")
.bind(token)
.bind(user_id)
.bind(expires_at)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_session_user(&self, token: &str) -> Result<Option<i64>> {
let row = sqlx::query("SELECT user_id FROM sessions WHERE token = ? AND expires_at > datetime('now')")
.bind(token)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.get(0)))
}
pub async fn delete_session(&self, token: &str) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(token)
.execute(&self.pool)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,124 @@
use crate::AppState;
use axum::{
extract::{State, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use time::Duration;
#[derive(Deserialize, ToSchema)]
pub struct LoginRequest {
username: String,
password: String,
}
#[allow(dead_code)]
#[derive(Serialize, ToSchema)]
pub struct UserResponse {
username: String,
}
#[utoipa::path(
post,
path = "/api/auth/login",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful"),
(status = 401, description = "Invalid credentials"),
(status = 500, description = "Internal server error")
)
)]
pub async fn login_handler(
State(state): State<AppState>,
jar: CookieJar,
Json(payload): Json<LoginRequest>,
) -> impl IntoResponse {
let user = match state.db.get_user_by_username(&payload.username).await {
Ok(Some(u)) => u,
Ok(None) => return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
Err(e) => {
tracing::error!("DB error during login: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
};
let (user_id, password_hash) = user;
match bcrypt::verify(&payload.password, &password_hash) {
Ok(true) => {
// Create session
let token: String = (0..32).map(|_| {
use rand::{distributions::Alphanumeric, Rng};
rand::thread_rng().sample(Alphanumeric) as char
}).collect();
// Expires in 30 days
let expires_in = 60 * 60 * 24 * 30;
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
tracing::error!("Failed to create session: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
}
let cookie = Cookie::build(("auth_token", token))
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.max_age(Duration::seconds(expires_in))
.build();
(StatusCode::OK, jar.add(cookie), "Login successful").into_response()
}
_ => (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response(),
}
}
#[utoipa::path(
post,
path = "/api/auth/logout",
responses(
(status = 200, description = "Logged out")
)
)]
pub async fn logout_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(token) = jar.get("auth_token") {
let _ = state.db.delete_session(token.value()).await;
}
let cookie = Cookie::build(("auth_token", ""))
.path("/")
.http_only(true)
.max_age(Duration::seconds(-1)) // Expire immediately
.build();
(StatusCode::OK, jar.add(cookie), "Logged out").into_response()
}
#[utoipa::path(
get,
path = "/api/auth/check",
responses(
(status = 200, description = "Authenticated"),
(status = 401, description = "Not authenticated")
)
)]
pub async fn check_auth_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(token) = jar.get("auth_token") {
match state.db.get_session_user(token.value()).await {
Ok(Some(_)) => return StatusCode::OK.into_response(),
_ => {} // Invalid session
}
}
StatusCode::UNAUTHORIZED.into_response()
}

View File

@@ -18,6 +18,9 @@ use shared::{
};
use utoipa::ToSchema;
pub mod auth;
pub mod setup;
#[derive(RustEmbed)]
#[folder = "../frontend/dist"]
pub struct Asset;

View File

@@ -0,0 +1,84 @@
use crate::AppState;
use axum::{
extract::{State, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Deserialize, ToSchema)]
pub struct SetupRequest {
username: String,
password: String,
}
#[derive(Serialize, ToSchema)]
pub struct SetupStatusResponse {
completed: bool,
}
#[utoipa::path(
get,
path = "/api/setup/status",
responses(
(status = 200, description = "Setup status", body = SetupStatusResponse)
)
)]
pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl IntoResponse {
let completed = match state.db.has_users().await {
Ok(has) => has,
Err(e) => {
tracing::error!("DB error checking users: {}", e);
false
}
};
Json(SetupStatusResponse { completed }).into_response()
}
#[utoipa::path(
post,
path = "/api/setup",
request_body = SetupRequest,
responses(
(status = 200, description = "Setup completed"),
(status = 400, description = "Invalid request"),
(status = 403, description = "Setup already completed"),
(status = 500, description = "Internal server error")
)
)]
pub async fn setup_handler(
State(state): State<AppState>,
Json(payload): Json<SetupRequest>,
) -> impl IntoResponse {
// 1. Check if setup is already completed (i.e., users exist)
match state.db.has_users().await {
Ok(true) => return (StatusCode::FORBIDDEN, "Setup already completed").into_response(),
Err(e) => {
tracing::error!("DB error checking users: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
Ok(false) => {} // Proceed
}
// 2. Validate input
if payload.username.len() < 3 || payload.password.len() < 6 {
return (StatusCode::BAD_REQUEST, "Username must be at least 3 chars, password at least 6").into_response();
}
// 3. Create User
let password_hash = match bcrypt::hash(&payload.password, bcrypt::DEFAULT_COST) {
Ok(h) => h,
Err(e) => {
tracing::error!("Failed to hash password: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process password").into_response();
}
};
if let Err(e) = state.db.create_user(&payload.username, &password_hash).await {
tracing::error!("Failed to create user: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
}
(StatusCode::OK, "Setup completed successfully").into_response()
}

View File

@@ -1,3 +1,4 @@
mod db;
mod diff;
mod handlers;
#[cfg(feature = "push-notifications")]
@@ -10,7 +11,12 @@ use axum::error_handling::HandleErrorLayer;
use axum::{
routing::{get, post},
Router,
middleware::{self, Next},
response::Response,
http::{StatusCode, Request},
body::Body,
};
use axum_extra::extract::cookie::CookieJar;
use clap::Parser;
use dotenvy::dotenv;
use shared::{AppEvent, Torrent};
@@ -32,10 +38,39 @@ pub struct AppState {
pub tx: Arc<watch::Sender<Vec<Torrent>>>,
pub event_bus: broadcast::Sender<AppEvent>,
pub scgi_socket_path: String,
pub db: db::Db,
#[cfg(feature = "push-notifications")]
pub push_store: push::PushSubscriptionStore,
}
async fn auth_middleware(
state: axum::extract::State<AppState>,
jar: CookieJar,
request: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
// Skip auth for public paths
let path = request.uri().path();
if path.starts_with("/api/auth/login")
|| path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|| path.starts_with("/api/setup")
|| path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs")
|| !path.starts_with("/api/") // Allow static files (frontend)
{
return Ok(next.run(request).await);
}
// Check token
if let Some(token) = jar.get("auth_token") {
match state.db.get_session_user(token.value()).await {
Ok(Some(_)) => return Ok(next.run(request).await),
_ => {} // Invalid
}
}
Err(StatusCode::UNAUTHORIZED)
}
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
@@ -51,6 +86,10 @@ struct Args {
/// Port to listen on
#[arg(short, long, env = "PORT", default_value_t = 3000)]
port: u16,
/// Database URL
#[arg(long, env = "DATABASE_URL", default_value = "sqlite:vibetorrent.db")]
db_url: String,
}
#[cfg(feature = "push-notifications")]
@@ -68,7 +107,12 @@ struct Args {
handlers::get_global_limit_handler,
handlers::set_global_limit_handler,
handlers::get_push_public_key_handler,
handlers::subscribe_push_handler
handlers::subscribe_push_handler,
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
handlers::setup::setup_handler,
handlers::setup::get_setup_status_handler
),
components(
schemas(
@@ -83,7 +127,10 @@ struct Args {
shared::SetLabelRequest,
shared::GlobalLimitRequest,
push::PushSubscription,
push::PushKeys
push::PushKeys,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse
)
),
tags(
@@ -105,7 +152,12 @@ struct ApiDoc;
handlers::set_file_priority_handler,
handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler
handlers::set_global_limit_handler,
handlers::auth::login_handler,
handlers::auth::logout_handler,
handlers::auth::check_auth_handler,
handlers::setup::setup_handler,
handlers::setup::get_setup_status_handler
),
components(
schemas(
@@ -118,7 +170,10 @@ struct ApiDoc;
shared::TorrentTracker,
shared::SetFilePriorityRequest,
shared::SetLabelRequest,
shared::GlobalLimitRequest
shared::GlobalLimitRequest,
handlers::auth::LoginRequest,
handlers::setup::SetupRequest,
handlers::setup::SetupStatusResponse
)
),
tags(
@@ -146,6 +201,29 @@ async fn main() {
tracing::info!("Socket: {}", args.socket);
tracing::info!("Port: {}", args.port);
// Initialize Database
tracing::info!("Connecting to database: {}", args.db_url);
// Ensure the db file exists if it's sqlite
if args.db_url.starts_with("sqlite:") {
let path = args.db_url.trim_start_matches("sqlite:");
if !std::path::Path::new(path).exists() {
tracing::info!("Database file not found, creating: {}", path);
match std::fs::File::create(path) {
Ok(_) => tracing::info!("Created empty database file"),
Err(e) => tracing::error!("Failed to create database file: {}", e),
}
}
}
let db: db::Db = match db::Db::new(&args.db_url).await {
Ok(db) => db,
Err(e) => {
tracing::error!("Failed to connect to database: {}", e);
std::process::exit(1);
}
};
tracing::info!("Database connected successfully.");
// Startup Health Check
let socket_path = std::path::Path::new(&args.socket);
if !socket_path.exists() {
@@ -181,6 +259,7 @@ async fn main() {
tx: tx.clone(),
event_bus: event_bus.clone(),
scgi_socket_path: args.socket.clone(),
db: db.clone(),
#[cfg(feature = "push-notifications")]
push_store: push::PushSubscriptionStore::new(),
};
@@ -308,6 +387,13 @@ async fn main() {
let app = Router::new()
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
// Setup & Auth Routes
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
.route("/api/setup", post(handlers::setup::setup_handler))
.route("/api/auth/login", post(handlers::auth::login_handler))
.route("/api/auth/logout", post(handlers::auth::logout_handler))
.route("/api/auth/check", get(handlers::auth::check_auth_handler))
// App Routes
.route("/api/events", get(sse::sse_handler))
.route("/api/torrents/add", post(handlers::add_torrent_handler))
.route(
@@ -344,6 +430,7 @@ async fn main() {
.route("/api/push/subscribe", post(handlers::subscribe_push_handler));
let app = app
.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware))
.layer(TraceLayer::new_for_http())
.layer(
CompressionLayer::new()

View File

@@ -3,15 +3,66 @@ use crate::components::layout::statusbar::StatusBar;
use crate::components::layout::toolbar::Toolbar;
use crate::components::toast::ToastContainer;
use crate::components::torrent::table::TorrentTable;
use crate::components::auth::login::Login;
use crate::components::auth::setup::Setup;
use leptos::*;
use leptos_router::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct SetupStatus {
completed: bool,
}
#[component]
pub fn App() -> impl IntoView {
crate::store::provide_torrent_store();
// Initialize push notifications after user grants permission
// Auth State
let (is_loading, set_is_loading) = create_signal(true);
let (is_authenticated, set_is_authenticated) = create_signal(false);
// Check Auth & Setup Status on load
create_effect(move |_| {
spawn_local(async move {
// 1. Check Setup Status
let setup_res = gloo_net::http::Request::get("/api/setup/status").send().await;
if let Ok(resp) = setup_res {
if let Ok(status) = resp.json::<SetupStatus>().await {
if !status.completed {
// Redirect to setup if not completed
let navigate = use_navigate();
navigate("/setup", Default::default());
set_is_loading.set(false);
return;
}
}
}
// 2. Check Auth Status
let auth_res = gloo_net::http::Request::get("/api/auth/check").send().await;
if let Ok(resp) = auth_res {
if resp.status() == 200 {
set_is_authenticated.set(true);
// Initialize push notifications logic only if authenticated
// ... (Push notification logic moved here or kept global but guarded)
} else {
let navigate = use_navigate();
// If we are already on login or setup, don't redirect loop
let pathname = window().location().pathname().unwrap_or_default();
if pathname != "/login" && pathname != "/setup" {
navigate("/login", Default::default());
}
}
}
set_is_loading.set(false);
});
});
// Initialize push notifications after user grants permission (Only if authenticated)
create_effect(move |_| {
if is_authenticated.get() {
spawn_local(async {
// Wait a bit for service worker to be ready
gloo_timers::future::TimeoutFuture::new(2000).await;
@@ -19,8 +70,6 @@ pub fn App() -> impl IntoView {
// Check if running on iOS and not standalone
if let Some(ios_message) = crate::utils::platform::get_ios_notification_info() {
log::warn!("iOS detected: {}", ios_message);
// Show toast to inform user
if let Some(store) = use_context::<crate::store::TorrentStore>() {
crate::store::show_toast_with_signal(
store.notifications,
@@ -31,16 +80,11 @@ pub fn App() -> impl IntoView {
return;
}
// Check if push notifications are supported
if !crate::utils::platform::supports_push_notifications() {
log::warn!("Push notifications not supported on this platform");
return;
}
// Safari requires user gesture for notification permission
// Don't auto-request on Safari - user should click a button
if crate::utils::platform::is_safari() {
log::info!("Safari detected - notification permission requires user interaction");
if let Some(store) = use_context::<crate::store::TorrentStore>() {
crate::store::show_toast_with_signal(
store.notifications,
@@ -51,16 +95,27 @@ pub fn App() -> impl IntoView {
return;
}
// For non-Safari browsers (Chrome, Firefox, Edge), attempt auto-subscribe
log::info!("Attempting to subscribe to push notifications...");
crate::store::subscribe_to_push_notifications().await;
});
}
});
view! {
// Main app wrapper - ensures proper stacking context
<div class="relative w-full h-screen" style="height: 100dvh;">
// Drawer layout
<Router>
<Routes>
<Route path="/login" view=move || view! { <Login /> } />
<Route path="/setup" view=move || view! { <Setup /> } />
<Route path="/*" view=move || {
view! {
<Show when=move || !is_loading.get() fallback=|| view! {
<div class="flex items-center justify-center h-screen bg-base-100">
<span class="loading loading-spinner loading-lg"></span>
</div>
}>
<Show when=move || is_authenticated.get() fallback=|| view! { <Login /> }>
// Protected Layout
<div class="drawer lg:drawer-open h-full w-full">
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
@@ -68,15 +123,12 @@ pub fn App() -> impl IntoView {
<Toolbar />
<main class="flex-1 flex flex-col min-w-0 bg-base-100 overflow-hidden pb-8">
<Router>
<Routes>
<Route path="/" view=move || view! { <TorrentTable /> } />
<Route path="/settings" view=move || view! { <div class="p-4">"Settings Page (Coming Soon)"</div> } />
</Routes>
</Router>
</main>
// StatusBar is rendered via fixed positioning, just mount it here
<StatusBar />
</div>
@@ -87,8 +139,13 @@ pub fn App() -> impl IntoView {
</div>
</div>
</div>
</Show>
</Show>
}
}/>
</Routes>
</Router>
// Toast container - fixed positioning relative to viewport
<ToastContainer />
</div>
}

View File

@@ -0,0 +1,110 @@
use leptos::*;
use leptos_router::*;
use serde::Serialize;
#[derive(Serialize)]
struct LoginRequest {
username: String,
password: String,
}
#[component]
pub fn Login() -> impl IntoView {
let (username, set_username) = create_signal(String::new());
let (password, set_password) = create_signal(String::new());
let (error, set_error) = create_signal(Option::<String>::None);
let (loading, set_loading) = create_signal(false);
let handle_login = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
set_loading.set(true);
set_error.set(None);
spawn_local(async move {
let req = LoginRequest {
username: username.get(),
password: password.get(),
};
let client = gloo_net::http::Request::post("/api/auth/login")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
if resp.ok() {
// Redirect to home on success
let navigate = use_navigate();
navigate("/", Default::default());
} else {
set_error.set(Some("Kullanıcı adı veya şifre hatalı".to_string()));
}
}
Err(_) => {
set_error.set(Some("Bağlantı hatası".to_string()));
}
}
set_loading.set(false);
});
};
view! {
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="card w-full max-w-sm shadow-xl bg-base-100">
<div class="card-body">
<h2 class="card-title justify-center mb-4">"VibeTorrent Giriş"</h2>
<form on:submit=handle_login>
<div class="form-control w-full">
<label class="label">
<span class="label-text">"Kullanıcı Adı"</span>
</label>
<input
type="text"
placeholder="Kullanıcı adınız"
class="input input-bordered w-full"
prop:value=username
on:input=move |ev| set_username.set(event_target_value(&ev))
disabled=move || loading.get()
/>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">"Şifre"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev))
disabled=move || loading.get()
/>
</div>
<Show when=move || error.get().is_some()>
<div class="alert alert-error mt-4 text-sm py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{move || error.get()}</span>
</div>
</Show>
<div class="card-actions justify-end mt-6">
<button
class="btn btn-primary w-full"
type="submit"
disabled=move || loading.get()
>
<Show when=move || loading.get() fallback=|| "Giriş Yap">
<span class="loading loading-spinner"></span>
"Giriş Yapılıyor..."
</Show>
</button>
</div>
</form>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,2 @@
pub mod login;
pub mod setup;

View File

@@ -0,0 +1,145 @@
use leptos::*;
use leptos_router::*;
use serde::Serialize;
#[derive(Serialize)]
struct SetupRequest {
username: String,
password: String,
}
#[component]
pub fn Setup() -> impl IntoView {
let (username, set_username) = create_signal(String::new());
let (password, set_password) = create_signal(String::new());
let (confirm_password, set_confirm_password) = create_signal(String::new());
let (error, set_error) = create_signal(Option::<String>::None);
let (loading, set_loading) = create_signal(false);
let handle_setup = move |ev: web_sys::SubmitEvent| {
ev.prevent_default();
set_loading.set(true);
set_error.set(None);
let pass = password.get();
let confirm = confirm_password.get();
if pass != confirm {
set_error.set(Some("Şifreler eşleşmiyor".to_string()));
set_loading.set(false);
return;
}
if pass.len() < 6 {
set_error.set(Some("Şifre en az 6 karakter olmalıdır".to_string()));
set_loading.set(false);
return;
}
spawn_local(async move {
let req = SetupRequest {
username: username.get(),
password: pass,
};
let client = gloo_net::http::Request::post("/api/setup")
.json(&req)
.expect("Failed to create request");
match client.send().await {
Ok(resp) => {
if resp.ok() {
// Redirect to login after setup
let navigate = use_navigate();
navigate("/login", Default::default());
} else {
let text = resp.text().await.unwrap_or_default();
set_error.set(Some(format!("Hata: {}", text)));
}
}
Err(_) => {
set_error.set(Some("Bağlantı hatası".to_string()));
}
}
set_loading.set(false);
});
};
view! {
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="card w-full max-w-md shadow-xl bg-base-100">
<div class="card-body">
<h2 class="card-title justify-center mb-2">"VibeTorrent Kurulumu"</h2>
<p class="text-center text-sm opacity-70 mb-4">"Yönetici hesabınızı oluşturun"</p>
<form on:submit=handle_setup>
<div class="form-control w-full">
<label class="label">
<span class="label-text">"Kullanıcı Adı"</span>
</label>
<input
type="text"
placeholder="admin"
class="input input-bordered w-full"
prop:value=username
on:input=move |ev| set_username.set(event_target_value(&ev))
disabled=move || loading.get()
required
/>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">"Şifre"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev))
disabled=move || loading.get()
required
/>
</div>
<div class="form-control w-full mt-4">
<label class="label">
<span class="label-text">"Şifre Tekrar"</span>
</label>
<input
type="password"
placeholder="******"
class="input input-bordered w-full"
prop:value=confirm_password
on:input=move |ev| set_confirm_password.set(event_target_value(&ev))
disabled=move || loading.get()
required
/>
</div>
<Show when=move || error.get().is_some()>
<div class="alert alert-error mt-4 text-sm py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{move || error.get()}</span>
</div>
</Show>
<div class="card-actions justify-end mt-6">
<button
class="btn btn-primary w-full"
type="submit"
disabled=move || loading.get()
>
<Show when=move || loading.get() fallback=|| "Kurulumu Tamamla">
<span class="loading loading-spinner"></span>
"İşleniyor..."
</Show>
</button>
</div>
</form>
</div>
</div>
</div>
}
}

View File

@@ -132,7 +132,7 @@ pub fn StatusBar() -> impl IntoView {
<Show when=move || active_dropdown.get() != 0>
<div
class="fixed inset-0 z-[98] cursor-default"
on:click=move |_| close_all()
on:pointerdown=move |_| close_all()
></div>
</Show>
@@ -143,7 +143,10 @@ pub fn StatusBar() -> impl IntoView {
<div
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Download Speed - Click to Set Limit"
on:click=move |_| toggle(1)
on:pointerdown=move |e| {
e.stop_propagation();
toggle(1);
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
@@ -170,7 +173,8 @@ pub fn StatusBar() -> impl IntoView {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| {
on:pointerdown=move |e| {
e.stop_propagation();
set_limit("down", val);
close_all();
}
@@ -192,7 +196,10 @@ pub fn StatusBar() -> impl IntoView {
<div
class="flex items-center gap-2 cursor-pointer hover:text-primary transition-colors select-none"
title="Global Upload Speed - Click to Set Limit"
on:click=move |_| toggle(2)
on:pointerdown=move |e| {
e.stop_propagation();
toggle(2);
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
@@ -219,7 +226,8 @@ pub fn StatusBar() -> impl IntoView {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold text-xs flex justify-between" } else { "text-xs flex justify-between" }
on:click=move |_| {
on:pointerdown=move |e| {
e.stop_propagation();
set_limit("up", val);
close_all();
}
@@ -241,7 +249,10 @@ pub fn StatusBar() -> impl IntoView {
<div
class="btn btn-ghost btn-xs btn-square"
title="Change Theme"
on:click=move |_| toggle(3)
on:pointerdown=move |e| {
e.stop_propagation();
toggle(3);
}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
@@ -261,7 +272,8 @@ pub fn StatusBar() -> impl IntoView {
<li>
<button
class=move || if current_theme.get() == theme { "bg-primary/10 text-primary font-bold text-xs capitalize" } else { "text-xs capitalize" }
on:click=move |_| {
on:pointerdown=move |e| {
e.stop_propagation();
set_current_theme.set(theme.to_string());
if let Some(win) = web_sys::window() {
if let Some(doc) = win.document() {
@@ -282,7 +294,6 @@ pub fn StatusBar() -> impl IntoView {
}
</ul>
</div>
<button
class="btn btn-ghost btn-xs btn-square"
title="Settings & Notification Permissions"

View File

@@ -3,3 +3,4 @@ pub mod layout;
pub mod modal;
pub mod toast;
pub mod torrent;
pub mod auth;

View File

@@ -329,7 +329,7 @@ pub fn TorrentTable() -> impl IntoView {
<Show when=move || sort_open.get()>
<div
class="fixed inset-0 z-[98] cursor-default"
on:click=move |_| set_sort_open.set(false)
on:pointerdown=move |_| set_sort_open.set(false)
></div>
</Show>
@@ -340,7 +340,8 @@ pub fn TorrentTable() -> impl IntoView {
<div
role="button"
class="btn btn-ghost btn-xs gap-1 opacity-70 font-normal"
on:click=move |_| {
on:pointerdown=move |e| {
e.stop_propagation();
let cur = sort_open.get_untracked();
set_sort_open.set(!cur);
}
@@ -353,6 +354,7 @@ pub fn TorrentTable() -> impl IntoView {
<ul
class="absolute top-full right-0 z-[100] menu p-2 shadow bg-base-100 rounded-box w-48 mt-1 border border-base-200 text-xs"
style=move || if sort_open.get() { "display: block" } else { "display: none" }
on:pointerdown=move |e| e.stop_propagation()
>
<li class="menu-title px-2 py-1 opacity-50 text-[10px] uppercase font-bold">"Sort By"</li>
{
@@ -374,7 +376,8 @@ pub fn TorrentTable() -> impl IntoView {
<li>
<button
class=move || if is_active() { "bg-primary/10 text-primary font-bold flex justify-between" } else { "flex justify-between" }
on:click=move |_| {
on:pointerdown=move |e| {
e.stop_propagation();
handle_sort(col);
set_sort_open.set(false);
}
@@ -397,8 +400,7 @@ pub fn TorrentTable() -> impl IntoView {
</div>
</div>
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3">
{move || filtered_torrents().into_iter().map(|t| {
<div class="overflow-y-auto p-3 pb-20 flex-1 grid grid-cols-1 content-start gap-3"> {move || filtered_torrents().into_iter().map(|t| {
let progress_class = if t.percent_complete >= 100.0 { "progress-success" } else { "progress-primary" };
let status_str = format!("{:?}", t.status);
let status_badge_class = match t.status {