Compare commits

...

2 Commits

Author SHA1 Message Date
Jef Roosens fc46c4874a
fix(web): refresh navbar on login and logout 2025-06-08 12:50:23 +02:00
Jef Roosens 957387bed7
feat(web): add logout button 2025-06-07 10:20:49 +02:00
6 changed files with 65 additions and 18 deletions

View File

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased](https://git.rustybever.be/Chewing_Bever/otter) ## [Unreleased](https://git.rustybever.be/Chewing_Bever/otter)
### Added
* Web UI
* Login/logout button
## [0.1.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.1.0) ## [0.1.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.1.0)
### Added ### Added

View File

@ -1,21 +1,21 @@
use axum::{ use axum::{
Form, RequestExt, Router,
extract::{Request, State}, extract::{Request, State},
http::HeaderMap, http::{HeaderMap, HeaderName, HeaderValue, header},
middleware::{self, Next}, middleware::{self, Next},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
routing::get, routing::{get, post},
Form, RequestExt, Router,
}; };
use axum_extra::{extract::CookieJar, headers::UserAgent, TypedHeader}; use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent};
use cookie::{time::Duration, Cookie}; use cookie::{Cookie, time::Duration};
use gpodder::{AuthErr, Session}; use gpodder::{AuthErr, Session};
use serde::Deserialize; use serde::Deserialize;
use crate::web::{Page, TemplateExt, TemplateResponse, View}; use crate::web::{Page, TemplateExt, TemplateResponse, View};
use super::{ use super::{
error::{AppError, AppResult},
Context, Context,
error::{AppError, AppResult},
}; };
const SESSION_ID_COOKIE: &str = "sessionid"; const SESSION_ID_COOKIE: &str = "sessionid";
@ -23,17 +23,27 @@ const SESSION_ID_COOKIE: &str = "sessionid";
pub fn router(ctx: Context) -> Router<Context> { pub fn router(ctx: Context) -> Router<Context> {
Router::new() Router::new()
.route("/", get(get_index)) .route("/", get(get_index))
.layer(middleware::from_fn_with_state( // .layer(middleware::from_fn_with_state(
ctx.clone(), // ctx.clone(),
auth_web_middleware, // auth_web_middleware,
)) // ))
// Login route needs to be handled differently, as the middleware turns it into a redirect // Login route needs to be handled differently, as the middleware turns it into a redirect
// loop // loop
.route("/login", get(get_login).post(post_login)) .route("/login", get(get_login).post(post_login))
.route("/logout", post(post_logout))
} }
async fn get_index(State(ctx): State<Context>, headers: HeaderMap) -> TemplateResponse<Page<View>> { async fn get_index(
View::Index.page(&headers).response(&ctx.tera) State(ctx): State<Context>,
headers: HeaderMap,
jar: CookieJar,
) -> AppResult<TemplateResponse<Page<View>>> {
let authenticated = extract_session(ctx.clone(), &jar).await?.is_some();
Ok(View::Index
.page(&headers)
.authenticated(authenticated)
.response(&ctx.tera))
} }
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response { async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
@ -78,15 +88,16 @@ async fn post_login(
.unwrap() .unwrap()
{ {
Ok(session) => Ok(( Ok(session) => Ok((
jar.add( // Redirect forces htmx to reload the full page, refreshing the navbar
[("HX-Redirect", "/")],
(jar.add(
Cookie::build((SESSION_ID_COOKIE, session.id.to_string())) Cookie::build((SESSION_ID_COOKIE, session.id.to_string()))
.secure(true) .secure(true)
.same_site(cookie::SameSite::Lax) .same_site(cookie::SameSite::Lax)
.http_only(true) .http_only(true)
.path("/") .path("/")
.max_age(Duration::days(365)), .max_age(Duration::days(365)),
), )),
Redirect::to("/"),
) )
.into_response()), .into_response()),
Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => { Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => {
@ -96,6 +107,16 @@ async fn post_login(
} }
} }
/// Log out the user by simply removing the session
async fn post_logout(State(ctx): State<Context>, jar: CookieJar) -> AppResult<impl IntoResponse> {
if let Some(session) = extract_session(ctx.clone(), &jar).await? {
ctx.store.remove_session(session.id)?;
}
// Redirect forces htmx to reload the full page, refreshing the navbar
Ok(([("HX-Redirect", "/")], jar.remove(SESSION_ID_COOKIE)))
}
async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> { async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
if let Some(session_id) = jar if let Some(session_id) = jar
.get(SESSION_ID_COOKIE) .get(SESSION_ID_COOKIE)

View File

@ -10,6 +10,7 @@ const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request";
pub struct Page<T> { pub struct Page<T> {
template: T, template: T,
wrap_with_base: bool, wrap_with_base: bool,
authenticated: bool,
} }
impl<T: Template> Template for Page<T> { impl<T: Template> Template for Page<T> {
@ -23,6 +24,7 @@ impl<T: Template> Template for Page<T> {
if self.wrap_with_base { if self.wrap_with_base {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("inner", &inner); ctx.insert("inner", &inner);
ctx.insert("authenticated", &self.authenticated);
tera.render(super::BASE_TEMPLATE, &ctx) tera.render(super::BASE_TEMPLATE, &ctx)
} else { } else {
@ -36,6 +38,7 @@ impl<T> Page<T> {
Self { Self {
template, template,
wrap_with_base: false, wrap_with_base: false,
authenticated: false,
} }
} }
@ -50,4 +53,10 @@ impl<T> Page<T> {
self self
} }
pub fn authenticated(mut self, authenticated: bool) -> Self {
self.authenticated = authenticated;
self
}
} }

View File

@ -15,6 +15,20 @@ a:hover {
<body> <body>
<main> <main>
<nav> <nav>
<ul>
<li>
<a hx-get="/" hx-target="#inner" hx-push-url="true"><strong>Otter</strong></a>
</li>
</ul>
<ul>
<li>
{% if authenticated %}
<a hx-post="/logout" hx-target="#inner">Logout</a>
{% else %}
<a hx-get="/login" hx-target="#inner" hx-push-url="true">Login</a>
{% endif %}
</li>
</ul>
</nav> </nav>
<article id="inner"> <article id="inner">
{{ inner | safe }} {{ inner | safe }}

View File

@ -1,5 +1,3 @@
<h1>Otter</h1> <h1>Otter</h1>
Otter is a self-hostable Gpodder implementation. Otter is a self-hostable Gpodder implementation.
If you're seeing this, you're logged in.

View File

@ -1,5 +1,5 @@
<article> <article>
<form hx-post="/login" hx-target="#inner"> <form hx-post="/login" hx-target="#inner" hx-push-url="/">
<label for="username">Username:</label> <label for="username">Username:</label>
<input type="text" id="username" name="username"> <input type="text" id="username" name="username">
<label for="password">Password:</label> <label for="password">Password:</label>