From 9cf8ce5d795ccc6bb217f04bfa9e30321556f1e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20G=C3=B6ttsch?= Date: Thu, 16 Jul 2026 12:38:36 +0200 Subject: [PATCH] feat(auth): generate additional invite links for onboarding users Add an authenticated POST /api/v1/invites endpoint that mints a single-use, 7-day registration link after the first-user bootstrap. Expose it via a new "Invite" section on the admin page and a `brewlog invite create` CLI command. The registration_tokens table, repository, and NewRegistrationToken already supported additional tokens; this adds the authenticated surfaces (session cookie or bearer token) to trigger creation. --- README.md | 11 +++ src/application/routes/api/auth/invites.rs | 75 +++++++++++++++ src/application/routes/api/auth/mod.rs | 1 + src/application/routes/api/mod.rs | 3 +- src/infrastructure/client/invites.rs | 34 +++++++ src/infrastructure/client/mod.rs | 5 + src/main.rs | 7 +- src/presentation/cli/invites.rs | 26 ++++++ src/presentation/cli/mod.rs | 8 ++ templates/pages/admin.html | 99 ++++++++++++++++++++ tests/cli/invites_cli.rs | 26 ++++++ tests/cli/main.rs | 1 + tests/server/auth_api.rs | 103 +++++++++++++++++++++ 13 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 src/application/routes/api/auth/invites.rs create mode 100644 src/infrastructure/client/invites.rs create mode 100644 src/presentation/cli/invites.rs create mode 100644 tests/cli/invites_cli.rs diff --git a/README.md b/README.md index 52922d0..88babdf 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ This link expires in 1 hour. Open that URL, choose a display name, and register a passkey. This creates an account and signs in automatically. +To onboard additional users later, generate a fresh invite link from the **Admin** page +("Invite" → "New Invite") or from the CLI (`brewlog invite create`). Each link is valid for +7 days and can be used once. + ### Install from Git To build and install from source, you'll need a working Rust toolchain: @@ -89,6 +93,13 @@ export BREWLOG_TOKEN="" brewlog roaster add --name "Radical Roasters" --country "United Kingdom" ``` +To invite another person, generate a single-use registration link (valid for 7 days): + +```bash +brewlog invite create +# Prints a https://.../register/ link to share with the new user +``` + Run `brewlog --help` for the full command reference. ## Configuration diff --git a/src/application/routes/api/auth/invites.rs b/src/application/routes/api/auth/invites.rs new file mode 100644 index 0000000..e392452 --- /dev/null +++ b/src/application/routes/api/auth/invites.rs @@ -0,0 +1,75 @@ +use axum::Json; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use chrono::{DateTime, Duration, Utc}; +use serde::Serialize; +use tracing::{error, info}; + +use crate::application::auth::AuthenticatedUser; +use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::support::{is_datastar_request, render_signals_json}; +use crate::application::state::AppState; +use crate::domain::registration_tokens::NewRegistrationToken; +use crate::infrastructure::auth::{generate_session_token, hash_token}; + +/// How long a generated invite link stays valid. Clamped by +/// `NewRegistrationToken::new` to `MAX_TOKEN_DURATION` (7 days). +const INVITE_VALIDITY: Duration = Duration::days(7); + +#[derive(Debug, Serialize)] +pub struct CreateInviteResponse { + pub url: String, + pub token: String, + pub expires_at: DateTime, +} + +/// Generate an additional registration link so an authenticated user can +/// onboard another person. Mirrors the first-user bootstrap in `server.rs`, +/// but requires authentication and works after users already exist. +#[tracing::instrument(skip(state, auth_user, headers))] +pub async fn create_invite( + State(state): State, + auth_user: AuthenticatedUser, + headers: HeaderMap, +) -> Result { + let token = generate_session_token(); + let token_hash = hash_token(&token); + let now = Utc::now(); + let expires_at = now.checked_add_signed(INVITE_VALIDITY).ok_or_else(|| { + error!("timestamp overflow computing invite expiry"); + ApiError::from(AppError::unexpected("failed to create invite")) + })?; + + let new_token = NewRegistrationToken::new(token_hash, now, expires_at); + + let stored = state + .registration_token_repo + .insert(new_token) + .await + .map_err(|err| { + error!(error = %err, "failed to store invite token"); + ApiError::from(AppError::unexpected("failed to create invite")) + })?; + + let url = format!("{}/register/{}", crate::base_url(), token); + + info!(invite_id = %stored.id, user_id = %auth_user.0.id, "invite link created"); + + if is_datastar_request(&headers) { + use serde_json::Value; + let signals = vec![ + ("_invite-url", Value::String(url)), + ("_invite-created", Value::Bool(true)), + ("_creating-invite", Value::Bool(false)), + ]; + render_signals_json(&signals).map_err(ApiError::from) + } else { + Ok(Json(CreateInviteResponse { + url, + token, + expires_at: stored.expires_at, + }) + .into_response()) + } +} diff --git a/src/application/routes/api/auth/mod.rs b/src/application/routes/api/auth/mod.rs index 211564b..71e9051 100644 --- a/src/application/routes/api/auth/mod.rs +++ b/src/application/routes/api/auth/mod.rs @@ -1,2 +1,3 @@ +pub(crate) mod invites; pub(crate) mod tokens; pub(crate) mod webauthn; diff --git a/src/application/routes/api/mod.rs b/src/application/routes/api/mod.rs index 8bc3e81..e1ca1f4 100644 --- a/src/application/routes/api/mod.rs +++ b/src/application/routes/api/mod.rs @@ -7,7 +7,7 @@ pub(crate) mod system; // Re-exports for backward compatibility pub(crate) use analytics::stats; -pub(crate) use auth::{tokens, webauthn}; +pub(crate) use auth::{invites, tokens, webauthn}; pub(crate) use coffee::{bags, brews, cafes, checkin, cups, gear, roasters, roasts, scan}; pub(crate) use system::{admin, backup, timeline}; @@ -91,6 +91,7 @@ pub(super) fn router() -> axum::Router { post(tokens::create_token).get(tokens::list_tokens), ) .route("/tokens/{id}/revoke", post(tokens::revoke_token)) + .route("/invites", post(invites::create_invite)) .route("/passkeys", get(admin::list_passkeys)) .route( "/passkeys/{id}", diff --git a/src/infrastructure/client/invites.rs b/src/infrastructure/client/invites.rs new file mode 100644 index 0000000..25d687f --- /dev/null +++ b/src/infrastructure/client/invites.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +use super::BrewlogClient; + +pub struct InvitesClient<'a> { + client: &'a BrewlogClient, +} + +impl<'a> InvitesClient<'a> { + pub fn new(client: &'a BrewlogClient) -> Self { + Self { client } + } + + pub async fn create(&self) -> Result { + let url = self.client.endpoint("api/v1/invites")?; + + let response = self + .client + .request(reqwest::Method::POST, url) + .send() + .await?; + + self.client.handle_response(response).await + } +} + +#[derive(Debug, Deserialize)] +pub struct InviteResponse { + pub url: String, + pub token: String, + pub expires_at: DateTime, +} diff --git a/src/infrastructure/client/mod.rs b/src/infrastructure/client/mod.rs index 4babba5..a70996b 100644 --- a/src/infrastructure/client/mod.rs +++ b/src/infrastructure/client/mod.rs @@ -4,6 +4,7 @@ pub mod brews; pub mod cafes; pub mod cups; pub mod gear; +pub mod invites; pub mod roasters; pub mod roasts; pub mod timeline; @@ -62,6 +63,10 @@ impl BrewlogClient { tokens::TokensClient::new(self) } + pub fn invites(&self) -> invites::InvitesClient<'_> { + invites::InvitesClient::new(self) + } + pub fn bags(&self) -> bags::BagsClient<'_> { bags::BagsClient::new(self) } diff --git a/src/main.rs b/src/main.rs index 1ab0159..848cf29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,8 @@ use brewlog::application::{ServerConfig, serve}; use brewlog::infrastructure::backup::BackupData; use brewlog::infrastructure::client::BrewlogClient; use brewlog::presentation::cli::{ - Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, timeline, tokens, + Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, invites, roasters, roasts, + timeline, tokens, }; use clap::Parser; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; @@ -51,6 +52,10 @@ async fn main() -> Result<()> { let client = BrewlogClient::from_base_url(&cli.api_url)?; tokens::run(&client, command).await } + Commands::Invite { command } => { + let client = BrewlogClient::from_base_url(&cli.api_url)?; + invites::run(&client, command).await + } Commands::Timeline { command } => { let client = BrewlogClient::from_base_url(&cli.api_url)?; match command { diff --git a/src/presentation/cli/invites.rs b/src/presentation/cli/invites.rs new file mode 100644 index 0000000..f304898 --- /dev/null +++ b/src/presentation/cli/invites.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::infrastructure::client::BrewlogClient; + +#[derive(Debug, Subcommand)] +pub enum InviteCommands { + /// Create a registration link for onboarding a new user + Create, +} + +pub async fn run(client: &BrewlogClient, cmd: InviteCommands) -> Result<()> { + match cmd { + InviteCommands::Create => create_invite(client).await, + } +} + +pub async fn create_invite(client: &BrewlogClient) -> Result<()> { + let invite = client.invites().create().await?; + + println!("Invite link created (expires {}):", invite.expires_at); + println!("\n {}\n", invite.url); + println!("Share this link. Opening it lets someone register a passkey and create an account."); + + Ok(()) +} diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index 9a43a46..dc48c01 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -4,6 +4,7 @@ pub mod brews; pub mod cafes; pub mod cups; pub mod gear; +pub mod invites; mod macros; pub mod roasters; pub mod roasts; @@ -21,6 +22,7 @@ use cafes::CafeCommands; use clap::{Args, Parser, Subcommand}; use cups::CupCommands; use gear::GearCommands; +use invites::InviteCommands; use roasters::RoasterCommands; use roasts::RoastCommands; use timeline::TimelineCommands; @@ -94,6 +96,12 @@ pub enum Commands { command: TokenCommands, }, + /// Manage invite links for onboarding new users + Invite { + #[command(subcommand)] + command: InviteCommands, + }, + /// Manage timeline events Timeline { #[command(subcommand)] diff --git a/templates/pages/admin.html b/templates/pages/admin.html index 2731d49..f975247 100644 --- a/templates/pages/admin.html +++ b/templates/pages/admin.html @@ -291,6 +291,85 @@ + +
+
+
+

Invite

+

+ Generate a registration link to onboard another user. The link is + valid for 7 days and can be used once. +

+
+ + + + + + +
+ +
+
+
+
@@ -464,6 +543,26 @@ } }; + // --- Invite --- + + const copyInvite = (btn) => { + const url = document.getElementById("invite-url").textContent; + if (navigator.clipboard) { + navigator.clipboard.writeText(url).then(() => { + btn.textContent = "Copied!"; + setTimeout(() => { + btn.textContent = "Copy"; + }, 2000); + }); + } else { + const range = document.createRange(); + range.selectNodeContents(document.getElementById("invite-url")); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + }; + // --- Data management --- const restoreFromFile = async (input) => { diff --git a/tests/cli/invites_cli.rs b/tests/cli/invites_cli.rs new file mode 100644 index 0000000..133b736 --- /dev/null +++ b/tests/cli/invites_cli.rs @@ -0,0 +1,26 @@ +use crate::helpers::{create_token, run_brewlog}; +use crate::test_macros::define_cli_auth_test; + +define_cli_auth_test!( + test_create_invite_requires_authentication, + &["invite", "create"] +); + +#[test] +fn test_create_invite_with_authentication() { + let token = create_token("test-create-invite"); + + let output = run_brewlog(&["invite", "create"], &[("BREWLOG_TOKEN", &token)]); + + assert!( + output.status.success(), + "invite create with auth should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("/register/"), + "Should print a registration link, got: {stdout}" + ); +} diff --git a/tests/cli/main.rs b/tests/cli/main.rs index 2734a8c..39ad740 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -5,6 +5,7 @@ pub mod cafes_cli; pub mod cups_cli; pub mod gear_cli; pub mod helpers; +pub mod invites_cli; pub mod roasters_cli; pub mod roasts_cli; pub mod test_macros; diff --git a/tests/server/auth_api.rs b/tests/server/auth_api.rs index 8042d1c..836c68a 100644 --- a/tests/server/auth_api.rs +++ b/tests/server/auth_api.rs @@ -274,6 +274,109 @@ async fn test_read_endpoints_dont_require_authentication() { assert_eq!(response.status(), StatusCode::OK); } +// --- Invite endpoint tests --- + +#[tokio::test] +async fn create_invite_requires_authentication() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + let response = client + .post(&app.api_url("/invites")) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn create_invite_with_bearer_auth_returns_link() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + let auth_token = app.auth_token.as_ref().unwrap(); + + let response = client + .post(&app.api_url("/invites")) + .bearer_auth(auth_token) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::OK); + + let body: serde_json::Value = response.json().await.expect("Failed to parse response"); + + let url = body.get("url").unwrap().as_str().unwrap(); + assert!( + url.contains("/register/"), + "invite url should point at the register page, got {url}" + ); + + let token = body.get("token").unwrap().as_str().unwrap(); + assert!(!token.is_empty(), "invite token should be non-empty"); + assert!( + url.ends_with(token), + "invite url should end with the raw token" + ); + + assert!( + body.get("expires_at").is_some(), + "invite response should include an expiry" + ); +} + +#[tokio::test] +async fn created_invite_link_opens_register_page() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + let auth_token = app.auth_token.as_ref().unwrap(); + + let create: serde_json::Value = client + .post(&app.api_url("/invites")) + .bearer_auth(auth_token) + .send() + .await + .expect("Failed to send request") + .json() + .await + .expect("Failed to parse response"); + + let token = create.get("token").unwrap().as_str().unwrap(); + + // The freshly minted token must be redeemable at the register page. + let response = client + .get(&app.page_url(&format!("/register/{token}"))) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn create_invite_datastar_returns_signals() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + let auth_token = app.auth_token.as_ref().unwrap(); + + let response = client + .post(&app.api_url("/invites")) + .bearer_auth(auth_token) + .header("datastar-request", "true") + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.text().await.expect("Failed to read response"); + assert!( + body.contains("_inviteUrl") && body.contains("_inviteCreated"), + "datastar response should patch invite signals, got {body}" + ); +} + // --- Admin passkey endpoint tests --- #[tokio::test]