Compare commits
	
		
			4 Commits 
		
	
	
		
			ee9db5ae36
			...
			09d782b6a5
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | 09d782b6a5 | |
|  | 332c05491a | |
|  | 5017bd1c5f | |
|  | b946e1ce98 | 
|  | @ -7,8 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |||
| 
 | ||||
| ## [Unreleased](https://git.rustybever.be/Chewing_Bever/otter) | ||||
| 
 | ||||
| ### Added | ||||
| 
 | ||||
| * Ability for an account to be an admin | ||||
| * CLI command to toggle admin status of users | ||||
| * Admin user management page | ||||
| 
 | ||||
| ## [0.3.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.3.0) | ||||
| 
 | ||||
| ### Added | ||||
| 
 | ||||
| * Public sign-up page (disabled by default) | ||||
| * Private sign-up links | ||||
| * New CLI commands | ||||
|  |  | |||
|  | @ -14,4 +14,8 @@ impl<'a> AdminRepository<'a> { | |||
|     ) -> Result<Vec<models::User>, AuthErr> { | ||||
|         self.store.paginated_users(page, filter) | ||||
|     } | ||||
| 
 | ||||
|     pub fn remove_user(&self, id: i64) -> Result<bool, AuthErr> { | ||||
|         self.store.remove_user(id) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -83,6 +83,10 @@ impl GpodderRepository { | |||
|         self.store.insert_user(username, &password_hash) | ||||
|     } | ||||
| 
 | ||||
|     pub fn update_user(&self, user: models::User) -> Result<models::User, AuthErr> { | ||||
|         self.store.update_user(user) | ||||
|     } | ||||
| 
 | ||||
|     pub fn validate_credentials( | ||||
|         &self, | ||||
|         username: &str, | ||||
|  |  | |||
|  | @ -51,6 +51,12 @@ pub trait GpodderAuthStore { | |||
|     /// Insert a new user into the data store
 | ||||
|     fn insert_user(&self, username: &str, password_hash: &str) -> Result<User, AuthErr>; | ||||
| 
 | ||||
|     /// Update the user with the included ID with the new values
 | ||||
|     fn update_user(&self, user: User) -> Result<User, AuthErr>; | ||||
| 
 | ||||
|     /// Remove the user with the given ID
 | ||||
|     fn remove_user(&self, id: i64) -> Result<bool, AuthErr>; | ||||
| 
 | ||||
|     /// Create a new session for a user with the given session ID
 | ||||
|     ///
 | ||||
|     /// The `last_seen` timestamp's precision should be at least accurate to the second
 | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ use diesel::prelude::*; | |||
| 
 | ||||
| use crate::schema::*; | ||||
| 
 | ||||
| #[derive(Clone, Queryable, Selectable)] | ||||
| #[derive(Clone, Queryable, Selectable, AsChangeset)] | ||||
| #[diesel(table_name = users)] | ||||
| #[diesel(check_for_backend(diesel::sqlite::Sqlite))] | ||||
| pub struct User { | ||||
|  |  | |||
|  | @ -24,6 +24,17 @@ impl From<User> for gpodder::User { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<gpodder::User> for User { | ||||
|     fn from(value: gpodder::User) -> Self { | ||||
|         Self { | ||||
|             id: value.id, | ||||
|             username: value.username, | ||||
|             password_hash: value.password_hash, | ||||
|             admin: value.admin, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<SignupLink> for gpodder::SignupLink { | ||||
|     fn from(value: SignupLink) -> Self { | ||||
|         Self { | ||||
|  | @ -58,6 +69,28 @@ impl gpodder::GpodderAuthStore for SqliteRepository { | |||
|             .map_err(DbError::from)?) | ||||
|     } | ||||
| 
 | ||||
|     fn update_user(&self, user: gpodder::User) -> Result<gpodder::User, AuthErr> { | ||||
|         let conn = &mut self.pool.get().map_err(DbError::from)?; | ||||
|         let user: User = user.into(); | ||||
| 
 | ||||
|         Ok(diesel::update(users::table.filter(users::id.eq(user.id))) | ||||
|             .set(&user) | ||||
|             .returning(User::as_returning()) | ||||
|             .get_result(conn) | ||||
|             .map(gpodder::User::from) | ||||
|             .map_err(DbError::from)?) | ||||
|     } | ||||
| 
 | ||||
|     fn remove_user(&self, id: i64) -> Result<bool, AuthErr> { | ||||
|         let conn = &mut self.pool.get().map_err(DbError::from)?; | ||||
| 
 | ||||
|         match diesel::delete(users::table.filter(users::id.eq(id))).execute(conn) { | ||||
|             Ok(0) => Ok(false), | ||||
|             Ok(_) => Ok(true), | ||||
|             Err(err) => Err(DbError::from(err).into()), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fn get_session(&self, session_id: i64) -> Result<Option<gpodder::models::Session>, AuthErr> { | ||||
|         match sessions::table | ||||
|             .inner_join(users::table) | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| use clap::{Args, Subcommand}; | ||||
| use clap::{ArgAction, Args, Subcommand}; | ||||
| 
 | ||||
| use super::CliError; | ||||
| 
 | ||||
|  | @ -22,6 +22,12 @@ pub enum UserCommand { | |||
|     Add { username: String, password: String }, | ||||
|     /// Generate a signup link ID
 | ||||
|     GenerateSignupLink, | ||||
|     /// Give or remove admin privileges to a user
 | ||||
|     SetAdmin { | ||||
|         username: String, | ||||
|         #[clap(action=ArgAction::Set)] | ||||
|         is_admin: bool, | ||||
|     }, | ||||
| } | ||||
| 
 | ||||
| impl Command { | ||||
|  | @ -67,6 +73,11 @@ impl UserCommand { | |||
| 
 | ||||
|                 println!("/signup/{}", link.id); | ||||
|             } | ||||
|             Self::SetAdmin { username, is_admin } => { | ||||
|                 let mut user = store.get_user(username)?; | ||||
|                 user.admin = *is_admin; | ||||
|                 store.update_user(user)?; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         Ok(()) | ||||
|  |  | |||
|  | @ -73,10 +73,12 @@ async fn get_index( | |||
|     headers: HeaderMap, | ||||
|     jar: CookieJar, | ||||
| ) -> AppResult<TemplateResponse<Page<View>>> { | ||||
|     let authenticated = auth::extract_session(ctx.clone(), &jar).await?.is_some(); | ||||
|     let user = auth::extract_session(ctx.clone(), &jar) | ||||
|         .await? | ||||
|         .map(|session| session.user); | ||||
| 
 | ||||
|     Ok(View::Index | ||||
|         .page(&headers) | ||||
|         .authenticated(authenticated) | ||||
|         .user(user.as_ref()) | ||||
|         .response(&ctx.tera)) | ||||
| } | ||||
|  |  | |||
|  | @ -30,6 +30,8 @@ pub async fn get_sessions( | |||
|     Query(page): Query<super::Pagination>, | ||||
| ) -> AppResult<TemplateResponse<Page<View>>> { | ||||
|     let next_page = page.next_page(); | ||||
|     let admin = session.user.admin; | ||||
| 
 | ||||
|     let sessions = tokio::task::spawn_blocking(move || { | ||||
|         ctx.store | ||||
|             .user(&session.user) | ||||
|  | @ -44,7 +46,7 @@ pub async fn get_sessions( | |||
|     Ok(View::Sessions(sessions, session.id, next_page_query) | ||||
|         .page(&headers) | ||||
|         .headers(&headers) | ||||
|         .authenticated(true) | ||||
|         .authenticated(true, admin) | ||||
|         .response(&ctx.tera)) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -17,6 +17,7 @@ use crate::{ | |||
| pub fn router(ctx: Context) -> Router<Context> { | ||||
|     Router::new() | ||||
|         .route("/users", get(get_users)) | ||||
|         .route("/users/{id}", delete(delete_user)) | ||||
|         .route_layer(axum::middleware::from_fn_with_state( | ||||
|             ctx.clone(), | ||||
|             super::auth::auth_web_middleware, | ||||
|  | @ -67,6 +68,23 @@ async fn get_users( | |||
|     Ok(View::Users(users, user_id, next_page_query) | ||||
|         .page(&headers) | ||||
|         .headers(&headers) | ||||
|         .authenticated(true) | ||||
|         .authenticated(true, true) | ||||
|         .response(&ctx.tera)) | ||||
| } | ||||
| 
 | ||||
| async fn delete_user( | ||||
|     State(ctx): State<Context>, | ||||
|     Extension(session): Extension<gpodder::Session>, | ||||
|     Path(id): Path<i64>, | ||||
| ) -> AppResult<()> { | ||||
|     let deleted = | ||||
|         tokio::task::spawn_blocking(move || ctx.store.admin(&session.user)?.remove_user(id)) | ||||
|             .await | ||||
|             .unwrap()?; | ||||
| 
 | ||||
|     if deleted { | ||||
|         Ok(()) | ||||
|     } else { | ||||
|         Err(AppError::NotFound) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ pub struct Page<T> { | |||
|     template: T, | ||||
|     wrap_with_base: bool, | ||||
|     authenticated: bool, | ||||
|     admin: bool, | ||||
| } | ||||
| 
 | ||||
| impl<T: Template> Template for Page<T> { | ||||
|  | @ -25,6 +26,7 @@ impl<T: Template> Template for Page<T> { | |||
|             let mut ctx = tera::Context::new(); | ||||
|             ctx.insert("inner", &inner); | ||||
|             ctx.insert("authenticated", &self.authenticated); | ||||
|             ctx.insert("admin", &self.admin); | ||||
| 
 | ||||
|             tera.render(super::BASE_TEMPLATE, &ctx) | ||||
|         } else { | ||||
|  | @ -39,6 +41,7 @@ impl<T> Page<T> { | |||
|             template, | ||||
|             wrap_with_base: false, | ||||
|             authenticated: false, | ||||
|             admin: false, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|  | @ -54,8 +57,23 @@ impl<T> Page<T> { | |||
|         self | ||||
|     } | ||||
| 
 | ||||
|     pub fn authenticated(mut self, authenticated: bool) -> Self { | ||||
|     /// Set the view's authentication level
 | ||||
|     pub fn authenticated(mut self, authenticated: bool, admin: bool) -> Self { | ||||
|         self.authenticated = authenticated; | ||||
|         self.admin = admin; | ||||
| 
 | ||||
|         self | ||||
|     } | ||||
| 
 | ||||
|     /// Utility function to derive authentication level from a given user
 | ||||
|     pub fn user(mut self, user: Option<&gpodder::User>) -> Self { | ||||
|         if let Some(user) = user { | ||||
|             self.authenticated = true; | ||||
|             self.admin = user.admin; | ||||
|         } else { | ||||
|             self.authenticated = false; | ||||
|             self.admin = false; | ||||
|         } | ||||
| 
 | ||||
|         self | ||||
|     } | ||||
|  |  | |||
|  | @ -23,6 +23,9 @@ a:hover { | |||
|                 <ul> | ||||
|                     {% if authenticated %} | ||||
|                     <li><a hx-get="/sessions" hx-target="#inner" hx-push-url="true">Sessions</a></li> | ||||
|                     {% if admin %} | ||||
|                     <li><a hx-get="/users" hx-target="#inner" hx-push-url="true">Users</a></li> | ||||
|                     {% endif %} | ||||
|                     <li><a hx-post="/logout" hx-target="#inner">Logout</a></li> | ||||
|                     {% else %} | ||||
|                     <li><a hx-get="/login" hx-target="#inner" hx-push-url="true">Login</a></li> | ||||
|  |  | |||
|  | @ -12,12 +12,18 @@ | |||
| <table id="users"> | ||||
|     <thead> | ||||
|         <th>Username</th> | ||||
|         <th>Privileges</th> | ||||
|         <th>Action</th> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|         {%- for user in users %} | ||||
|         <tr> | ||||
|             <th>{{ user.username }}</th> | ||||
|             <th>{%- if user.admin -%} | ||||
|                 Admin | ||||
|                 {%- else -%} | ||||
|                 User | ||||
|                 {%- endif -%} | ||||
|             <th> | ||||
|                 {%- if user.id != current_user_id -%} | ||||
|                 <a hx-delete="/users/{{ user.id }}" | ||||
|  |  | |||
|  | @ -28,6 +28,7 @@ struct Session { | |||
| struct User { | ||||
|     id: i64, | ||||
|     username: String, | ||||
|     admin: bool, | ||||
| } | ||||
| 
 | ||||
| impl Template for View { | ||||
|  | @ -103,6 +104,7 @@ impl From<gpodder::User> for User { | |||
|         Self { | ||||
|             id: value.id, | ||||
|             username: value.username, | ||||
|             admin: value.admin, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue