feat(frontend): add login page and session-based authentication
- Add tower and tower-cookies dependencies for session management - Create login page template with username/password form - Implement /login and /logout routes with cookie-based sessions - Update navigation bar to show Login/Logout based on auth state - Add is_authenticated field to all page templates - Hide create/update/delete UI controls when unauthenticated - Session tokens stored in secure HttpOnly cookies with SameSite=Lax - Password verification uses constant-time comparison via Argon2 Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
parent
650ca7a372
commit
e6811d45ad
13 changed files with 297 additions and 4 deletions
99
Cargo.lock
generated
99
Cargo.lock
generated
|
|
@ -222,7 +222,7 @@ dependencies = [
|
|||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -350,6 +350,8 @@ dependencies = [
|
|||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-cookies",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wiremock",
|
||||
|
|
@ -461,6 +463,17 @@ version = "0.9.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
|
|
@ -555,6 +568,15 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -1426,6 +1448,12 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
|
|
@ -1634,6 +1662,12 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
|
|
@ -1864,7 +1898,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower 0.5.2",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
|
|
@ -2558,6 +2592,37 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.2"
|
||||
|
|
@ -2654,6 +2719,17 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.2"
|
||||
|
|
@ -2670,6 +2746,23 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-cookies"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fd0118512cf0b3768f7fcccf0bef1ae41d68f2b45edc1e77432b36c97c56c6d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"cookie",
|
||||
"futures-util",
|
||||
"http",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.6"
|
||||
|
|
@ -2683,7 +2776,7 @@ dependencies = [
|
|||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ thiserror = "1.0"
|
|||
tokio = { version = "1.38", features = ["rt-multi-thread", "macros", "signal"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower = "0.4"
|
||||
tower-cookies = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
portpicker = "0.1"
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ pub fn hash_token(token: &str) -> String {
|
|||
general_purpose::STANDARD.encode(&result)
|
||||
}
|
||||
|
||||
/// Generates a session token for cookie-based authentication
|
||||
pub fn generate_session_token() -> String {
|
||||
let mut token_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut token_bytes);
|
||||
general_purpose::URL_SAFE_NO_PAD.encode(&token_bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::domain::timeline::TimelineSortKey;
|
|||
#[template(path = "roasters.html")]
|
||||
pub struct RoastersTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub roasters: Paginated<RoasterView>,
|
||||
pub navigator: ListNavigator<RoasterSortKey>,
|
||||
}
|
||||
|
|
@ -27,6 +28,7 @@ pub struct RoasterListTemplate {
|
|||
#[template(path = "roaster_detail.html")]
|
||||
pub struct RoasterDetailTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub roaster: RoasterView,
|
||||
pub roasts: Vec<RoastView>,
|
||||
}
|
||||
|
|
@ -35,6 +37,7 @@ pub struct RoasterDetailTemplate {
|
|||
#[template(path = "roasts.html")]
|
||||
pub struct RoastsTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub roasts: Paginated<RoastView>,
|
||||
pub roaster_options: Vec<RoasterOptionView>,
|
||||
pub navigator: ListNavigator<RoastSortKey>,
|
||||
|
|
@ -44,6 +47,7 @@ pub struct RoastsTemplate {
|
|||
#[template(path = "roast_detail.html")]
|
||||
pub struct RoastDetailTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub roast: RoastView,
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +62,7 @@ pub struct RoastListTemplate {
|
|||
#[template(path = "timeline.html")]
|
||||
pub struct TimelineTemplate {
|
||||
pub nav_active: &'static str,
|
||||
pub is_authenticated: bool,
|
||||
pub events: Paginated<TimelineEventView>,
|
||||
pub navigator: ListNavigator<TimelineSortKey>,
|
||||
pub months: Vec<TimelineMonthView>,
|
||||
|
|
|
|||
99
src/server/routes/auth.rs
Normal file
99
src/server/routes/auth.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
use askama::Template;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::Form;
|
||||
use serde::Deserialize;
|
||||
use tower_cookies::{Cookie, Cookies};
|
||||
use tracing::warn;
|
||||
use crate::infrastructure::auth::{generate_session_token, verify_password};
|
||||
use crate::server::routes::render_html;
|
||||
use crate::server::server::AppState;
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "brewlog_session";
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate {
|
||||
nav_active: &'static str,
|
||||
is_authenticated: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn login_page(cookies: Cookies) -> Result<Response, StatusCode> {
|
||||
// Check if already authenticated
|
||||
if cookies.get(SESSION_COOKIE_NAME).is_some() {
|
||||
return Ok(Redirect::to("/timeline").into_response());
|
||||
}
|
||||
|
||||
let template = LoginTemplate {
|
||||
nav_active: "login",
|
||||
is_authenticated: false,
|
||||
error: None,
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
pub(crate) async fn login_submit(
|
||||
State(state): State<AppState>,
|
||||
cookies: Cookies,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Validate credentials
|
||||
let user = match state.user_repo.get_by_username(&form.username).await {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
warn!(username = %form.username, error = %err, "login attempt with non-existent username or error");
|
||||
return show_login_error("Invalid username or password");
|
||||
}
|
||||
};
|
||||
|
||||
// Verify password
|
||||
if !verify_password(&form.password, &user.password_hash)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
warn!(username = %form.username, "login attempt with incorrect password");
|
||||
return show_login_error("Invalid username or password");
|
||||
}
|
||||
|
||||
// Create session token
|
||||
let session_token = generate_session_token();
|
||||
|
||||
// Set secure cookie
|
||||
let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token);
|
||||
cookie.set_path("/");
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_same_site(tower_cookies::cookie::SameSite::Lax);
|
||||
// In production, set secure flag: cookie.set_secure(true);
|
||||
|
||||
cookies.add(cookie);
|
||||
|
||||
Ok(Redirect::to("/timeline").into_response())
|
||||
}
|
||||
|
||||
pub(crate) async fn logout(cookies: Cookies) -> Redirect {
|
||||
cookies.remove(Cookie::from(SESSION_COOKIE_NAME));
|
||||
Redirect::to("/timeline")
|
||||
}
|
||||
|
||||
fn show_login_error(message: &str) -> Result<Response, StatusCode> {
|
||||
let template = LoginTemplate {
|
||||
nav_active: "login",
|
||||
is_authenticated: false,
|
||||
error: Some(message.to_string()),
|
||||
};
|
||||
|
||||
render_html(template).map(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
/// Check if user is authenticated based on session cookie
|
||||
pub fn is_authenticated(cookies: &Cookies) -> bool {
|
||||
cookies.get(SESSION_COOKIE_NAME).is_some()
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod auth;
|
||||
pub mod roasters;
|
||||
pub mod roasts;
|
||||
pub mod support;
|
||||
|
|
@ -8,6 +9,8 @@ use askama::Template;
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Redirect};
|
||||
use axum::routing::{get, post};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
use tracing::error;
|
||||
|
||||
use crate::server::server::AppState;
|
||||
|
|
@ -43,6 +46,8 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
|
||||
axum::Router::new()
|
||||
.route("/", get(root_redirect))
|
||||
.route("/login", get(auth::login_page).post(auth::login_submit))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/roasters", get(roasters::roasters_page))
|
||||
.route("/roasters/:id", get(roasters::roaster_page))
|
||||
.route("/roasts", get(roasts::roasts_page))
|
||||
|
|
@ -51,6 +56,7 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
.route("/styles.css", get(styles))
|
||||
.route("/favicon.ico", get(favicon))
|
||||
.nest("/api/v1", api_routes)
|
||||
.layer(ServiceBuilder::new().layer(CookieManagerLayer::new()))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ async fn load_roaster_page(
|
|||
|
||||
pub(crate) async fn roasters_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
|
|
@ -55,8 +56,11 @@ pub(crate) async fn roasters_page(
|
|||
.await
|
||||
.map_err(|err| map_app_error(err))?;
|
||||
|
||||
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
|
||||
|
||||
let template = RoastersTemplate {
|
||||
nav_active: "roasters",
|
||||
is_authenticated,
|
||||
roasters,
|
||||
navigator,
|
||||
};
|
||||
|
|
@ -66,6 +70,7 @@ pub(crate) async fn roasters_page(
|
|||
|
||||
pub(crate) async fn roaster_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Html<String>, StatusCode> {
|
||||
let roaster = state
|
||||
|
|
@ -80,9 +85,11 @@ pub(crate) async fn roaster_page(
|
|||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let roaster_view = RoasterView::from(roaster);
|
||||
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
|
||||
|
||||
let template = RoasterDetailTemplate {
|
||||
nav_active: "roasters",
|
||||
is_authenticated,
|
||||
roaster: roaster_view,
|
||||
roasts: roasts.into_iter().map(RoastView::from_list_item).collect(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ async fn load_roast_page(
|
|||
|
||||
pub(crate) async fn roasts_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
|
|
@ -62,8 +63,11 @@ pub(crate) async fn roasts_page(
|
|||
.await
|
||||
.map_err(|err| map_app_error(err))?;
|
||||
|
||||
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
|
||||
|
||||
let template = RoastsTemplate {
|
||||
nav_active: "roasts",
|
||||
is_authenticated,
|
||||
roasts,
|
||||
roaster_options,
|
||||
navigator,
|
||||
|
|
@ -74,6 +78,7 @@ pub(crate) async fn roasts_page(
|
|||
|
||||
pub(crate) async fn roast_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Html<String>, StatusCode> {
|
||||
let roast = state
|
||||
|
|
@ -87,8 +92,11 @@ pub(crate) async fn roast_page(
|
|||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
|
||||
|
||||
let template = RoastDetailTemplate {
|
||||
nav_active: "roasts",
|
||||
is_authenticated,
|
||||
roast: RoastView::from_domain(roast, &roaster.name),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const TIMELINE_DEFAULT_PAGE_SIZE: u32 = 5;
|
|||
|
||||
pub(crate) async fn timeline_page(
|
||||
State(state): State<AppState>,
|
||||
cookies: tower_cookies::Cookies,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
|
|
@ -34,8 +35,11 @@ pub(crate) async fn timeline_page(
|
|||
.await
|
||||
.map_err(|err| map_app_error(err))?;
|
||||
|
||||
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
|
||||
|
||||
let template = TimelineTemplate {
|
||||
nav_active: "timeline",
|
||||
is_authenticated,
|
||||
events: data.events,
|
||||
navigator: data.navigator,
|
||||
months: data.months,
|
||||
|
|
|
|||
49
templates/login.html
Normal file
49
templates/login.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Brewlog · Login{% endblock %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-100/80 p-6 shadow-sm">
|
||||
<h1 class="text-2xl font-semibold text-amber-700">Login</h1>
|
||||
<p class="mt-2 text-sm text-stone-600">
|
||||
Sign in to manage your roasters and roasts.
|
||||
</p>
|
||||
|
||||
{% if error.is_some() %}
|
||||
<div class="mt-4 rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800">
|
||||
{{ error.as_ref().unwrap() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login" class="mt-6 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Username</span>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
class="input-field"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="text-stone-700">Password</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
class="input-field"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 rounded-md bg-amber-600 px-4 py-3 text-sm font-semibold text-amber-50 transition hover:bg-amber-500"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -4,5 +4,12 @@
|
|||
<a class="border-b-2 pb-1 transition {% if nav_active == "roasters" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasters">Roasters</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "roasts" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/roasts">Roasts</a>
|
||||
<a class="border-b-2 pb-1 transition {% if nav_active == "timeline" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/timeline">Timeline</a>
|
||||
{% if is_authenticated %}
|
||||
<form method="post" action="/logout" class="inline">
|
||||
<button type="submit" class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400">Logout</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400" href="/login">Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<h1 class="text-3xl font-semibold">Roasters</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Browse the coffee roasters known to Brewlog.</p>
|
||||
</div>
|
||||
{% if is_authenticated %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
|
|
@ -14,8 +15,10 @@
|
|||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm"
|
||||
data-show="$showForm"
|
||||
|
|
@ -96,6 +99,7 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% include "partials/roaster_list.html" %} {% endblock %}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<h1 class="text-3xl font-semibold">Roasts</h1>
|
||||
<p class="max-w-2xl text-sm text-stone-600">Explore the latest roasts logged in Brewlog.</p>
|
||||
</div>
|
||||
{% if !roaster_options.is_empty() %}
|
||||
{% if is_authenticated && !roaster_options.is_empty() %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500 text-2xl font-semibold text-amber-700 transition hover:border-amber-400 hover:text-amber-600"
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if is_authenticated %}
|
||||
{% if roaster_options.is_empty() %}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-dashed border-amber-300 bg-amber-100/60 p-5 text-sm text-stone-600"
|
||||
|
|
@ -122,6 +123,7 @@
|
|||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% include "partials/roast_list.html" %} {% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue