Setup page

This commit is contained in:
Greg Shuflin 2025-02-02 20:44:00 -08:00
parent 239d7dd94a
commit 89c19e08d9
5 changed files with 247 additions and 5 deletions

View File

@ -0,0 +1,2 @@
-- Add admin field to users table
ALTER TABLE users ADD COLUMN admin BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -10,7 +10,7 @@ mod user;
use rocket::fs::FileServer; use rocket::fs::FileServer;
use rocket::response::Redirect; use rocket::response::Redirect;
use rocket_db_pools::{sqlx, Database}; use rocket_db_pools::{sqlx, Database, Connection};
use rocket_dyn_templates::{context, Template}; use rocket_dyn_templates::{context, Template};
use user::AuthenticatedUser; use user::AuthenticatedUser;
@ -33,8 +33,20 @@ fn index(_user: AuthenticatedUser) -> Template {
} }
#[get("/", rank = 2)] #[get("/", rank = 2)]
fn index_redirect() -> Redirect { async fn index_redirect(mut db: Connection<Db>) -> Redirect {
Redirect::to(uri!(login)) // Check if any users exist
let count = sqlx::query!("SELECT COUNT(*) as count FROM users")
.fetch_one(&mut **db)
.await
.map_err(|_| Redirect::to(uri!(login)))
.unwrap()
.count;
if count == 0 {
Redirect::to(uri!(user::setup_page))
} else {
Redirect::to(uri!(login))
}
} }
#[get("/login")] #[get("/login")]
@ -48,6 +60,12 @@ fn rocket() -> _ {
let db_url = format!("sqlite:{}", args.database); let db_url = format!("sqlite:{}", args.database);
// Check if database file exists, create it if it doesn't
if !std::path::Path::new(&args.database).exists() {
use std::fs::File;
File::create(&args.database).expect("Failed to create database file");
}
// Run migrations before starting the server // Run migrations before starting the server
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { rt.block_on(async {
@ -75,6 +93,8 @@ fn rocket() -> _ {
user::delete_user, user::delete_user,
user::login, user::login,
user::logout, user::logout,
user::setup_page,
user::setup,
feeds::create_feed, feeds::create_feed,
feeds::get_feed, feeds::get_feed,
feeds::list_feeds, feeds::list_feeds,

View File

@ -1,6 +1,7 @@
use rocket::http::{Cookie, CookieJar, Status}; use rocket::http::{Cookie, CookieJar, Status};
use rocket::serde::{json::Json, Deserialize, Serialize}; use rocket::serde::{json::Json, Deserialize, Serialize};
use rocket_db_pools::Connection; use rocket_db_pools::Connection;
use rocket_dyn_templates::{context, Template};
use uuid::Uuid; use uuid::Uuid;
use crate::Db; use crate::Db;
@ -14,6 +15,7 @@ pub struct User {
pub email: Option<String>, pub email: Option<String>,
pub display_name: Option<String>, pub display_name: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
pub admin: bool,
} }
impl User { impl User {
@ -30,6 +32,7 @@ impl User {
email, email,
display_name, display_name,
created_at: chrono::Utc::now(), created_at: chrono::Utc::now(),
admin: false,
} }
} }
} }
@ -57,6 +60,12 @@ pub struct LoginResponse {
username: String, username: String,
} }
#[derive(Debug, Serialize)]
#[serde(crate = "rocket::serde")]
pub struct SetupError {
error: String,
}
#[post("/users", data = "<new_user>")] #[post("/users", data = "<new_user>")]
pub async fn create_user( pub async fn create_user(
mut db: Connection<Db>, mut db: Connection<Db>,
@ -76,7 +85,7 @@ pub async fn create_user(
); );
let query = sqlx::query( let query = sqlx::query(
"INSERT INTO users (id, username, password_hash, email, display_name, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)" "INSERT INTO users (id, username, password_hash, email, display_name, created_at, admin) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
) )
.bind(user.id.to_string()) .bind(user.id.to_string())
.bind(user.username.as_str()) .bind(user.username.as_str())
@ -84,6 +93,7 @@ pub async fn create_user(
.bind(user.email.as_ref()) .bind(user.email.as_ref())
.bind(user.display_name.as_ref()) .bind(user.display_name.as_ref())
.bind(user.created_at.to_rfc3339()) .bind(user.created_at.to_rfc3339())
.bind(false)
.execute(&mut **db).await; .execute(&mut **db).await;
match query { match query {
@ -110,7 +120,8 @@ pub async fn get_users(mut db: Connection<Db>) -> Result<Json<Vec<User>>, Status
password_hash, password_hash,
email, email,
display_name, display_name,
created_at as "created_at: chrono::DateTime<chrono::Utc>" created_at as "created_at: chrono::DateTime<chrono::Utc>",
admin as "admin: bool"
FROM users FROM users
"# "#
) )
@ -131,6 +142,7 @@ pub async fn get_users(mut db: Connection<Db>) -> Result<Json<Vec<User>>, Status
email: row.email, email: row.email,
display_name: row.display_name, display_name: row.display_name,
created_at: row.created_at, created_at: row.created_at,
admin: row.admin,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -247,3 +259,83 @@ impl<'r> rocket::request::FromRequest<'r> for AuthenticatedUser {
} }
} }
} }
#[get("/setup")]
pub async fn setup_page(mut db: Connection<Db>) -> Result<Template, Status> {
// Check if any users exist
let count = sqlx::query!("SELECT COUNT(*) as count FROM users")
.fetch_one(&mut **db)
.await
.map_err(|_| Status::InternalServerError)?
.count;
if count > 0 {
// If users exist, redirect to login
Err(Status::SeeOther)
} else {
// Show setup page
Ok(Template::render("setup", context! {}))
}
}
#[post("/setup", data = "<new_user>")]
pub async fn setup(mut db: Connection<Db>, new_user: Json<NewUser>) -> Result<Status, Json<SetupError>> {
// Check if any users exist
let count = sqlx::query!("SELECT COUNT(*) as count FROM users")
.fetch_one(&mut **db)
.await
.map_err(|e| {
eprintln!("Database error: {}", e);
Json(SetupError {
error: "Internal server error".to_string(),
})
})?
.count;
if count > 0 {
return Err(Json(SetupError {
error: "Setup has already been completed".to_string(),
}));
}
// Hash the password
let password_hash = bcrypt::hash(new_user.password.as_bytes(), bcrypt::DEFAULT_COST).map_err(|e| {
eprintln!("Password hashing error: {}", e);
Json(SetupError {
error: "Failed to process password".to_string(),
})
})?;
// Create admin user
let user_id = Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
let result = sqlx::query(
"INSERT INTO users (id, username, password_hash, email, display_name, created_at, admin)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.bind(&user_id)
.bind(&new_user.username)
.bind(&password_hash)
.bind(&new_user.email)
.bind(&new_user.display_name)
.bind(&now)
.bind(true) // This is an admin user
.execute(&mut **db)
.await;
match result {
Ok(_) => Ok(Status::Created),
Err(e) => {
eprintln!("Database error: {}", e);
match e {
sqlx::Error::Database(db_err) if db_err.is_unique_violation() => Err(Json(SetupError {
error: "Username already exists".to_string(),
})),
_ => Err(Json(SetupError {
error: "Failed to create user".to_string(),
})),
}
}
}
}

View File

@ -384,4 +384,60 @@ button:disabled {
padding: 1rem; padding: 1rem;
background-color: rgba(244, 63, 63, 0.1); background-color: rgba(244, 63, 63, 0.1);
border-radius: 4px; border-radius: 4px;
}
.setup-container {
max-width: 500px;
margin: 50px auto;
padding: 2rem;
background-color: var(--sidebar-bg);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.setup-container h1 {
text-align: center;
margin-bottom: 1rem;
color: var(--text-color);
}
.setup-container p {
text-align: center;
margin-bottom: 2rem;
color: var(--text-muted);
}
.setup-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.setup-form .form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setup-form label {
color: var(--text-color);
font-weight: 500;
}
.setup-form input {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--input-bg);
color: var(--text-color);
}
.setup-form input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.2);
}
.setup-form button {
margin-top: 1rem;
} }

72
templates/setup.html.tera Normal file
View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Initial Setup - RSS Reader</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="setup-container">
<h1>Welcome to RSS Reader</h1>
<p>Please create your initial admin user account.</p>
<form id="setupForm" class="setup-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="email">Email (optional)</label>
<input type="email" id="email" name="email">
</div>
<div class="form-group">
<label for="displayName">Display Name (optional)</label>
<input type="text" id="displayName" name="displayName">
</div>
<div class="error-message" id="setupError" style="display: none;"></div>
<button type="submit" class="btn-primary">Create Admin Account</button>
</form>
</div>
<script>
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const errorElement = document.getElementById('setupError');
errorElement.style.display = 'none';
const formData = {
username: document.getElementById('username').value,
password: document.getElementById('password').value,
email: document.getElementById('email').value || null,
display_name: document.getElementById('displayName').value || null
};
try {
const response = await fetch('/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
window.location.href = '/login';
} else {
const data = await response.json().catch(() => null);
errorElement.textContent = data?.error || 'Failed to create admin account';
errorElement.style.display = 'block';
}
} catch (error) {
errorElement.textContent = 'An error occurred. Please try again.';
errorElement.style.display = 'block';
}
});
</script>
</body>
</html>