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.
This commit is contained in:
Niels Göttsch 2026-07-16 12:38:36 +02:00
parent 22c0a30d38
commit 9cf8ce5d79
13 changed files with 397 additions and 2 deletions

View file

@ -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="<token from above>"
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/<token> link to share with the new user
```
Run `brewlog --help` for the full command reference.
## Configuration

View file

@ -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<Utc>,
}
/// 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<AppState>,
auth_user: AuthenticatedUser,
headers: HeaderMap,
) -> Result<Response, ApiError> {
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())
}
}

View file

@ -1,2 +1,3 @@
pub(crate) mod invites;
pub(crate) mod tokens;
pub(crate) mod webauthn;

View file

@ -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<AppState> {
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}",

View file

@ -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<InviteResponse> {
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<Utc>,
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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(())
}

View file

@ -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)]

View file

@ -291,6 +291,85 @@
</div>
</section>
<!-- Invite -->
<section
class="rounded-lg border bg-surface p-5"
data-signals:_creating-invite="false"
data-signals:_invite-created="false"
data-signals:_invite-url="''"
data-signals:_invite-error="''"
>
<div class="flex flex-col gap-4">
<div>
<h2 class="text-lg font-semibold text-text">Invite</h2>
<p class="mt-1 text-sm text-text-secondary">
Generate a registration link to onboard another user. The link is
valid for 7 days and can be used once.
</p>
</div>
<p
data-show="$_inviteError"
data-text="$_inviteError"
style="display: none"
class="rounded-md bg-error-bg border border-error-border p-2 text-sm text-error-text"
role="alert"
></p>
<!-- One-time invite link display -->
<div
data-show="$_inviteCreated"
style="display: none"
class="relative rounded-md border border-success-border bg-success-bg p-4"
>
<button
type="button"
onclick="window.location.reload()"
class="absolute top-3.5 right-3 inline-flex h-6 w-6 items-center justify-center rounded text-success-text transition hover:text-accent-text"
aria-label="Dismiss"
>
{{ icons::x_mark("h-4 w-4") }}
</button>
<p class="pr-6 text-sm font-medium text-success-text">
Invite link created! Share it with the new user — it expires in 7
days and can be used once.
</p>
<div class="mt-3 flex items-center gap-2">
<code
id="invite-url"
data-text="$_inviteUrl"
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-success-border break-all select-all"
></code>
<button
type="button"
onclick="copyInvite(this)"
class="shrink-0 inline-flex items-center gap-2 rounded-md bg-success px-3 py-1.5 text-sm font-medium text-accent-text transition hover:bg-success"
>
{{ icons::clipboard("h-4 w-4") }} Copy
</button>
</div>
</div>
<div data-show="!$_inviteCreated">
<button
type="button"
data-on:click="$_creatingInvite = true; $_inviteError = ''; @post('/api/v1/invites')"
data-on:datastar-fetch="if (!$_creatingInvite) return;
if (evt.detail.type === 'finished') { $_creatingInvite = false }
else if (evt.detail.type === 'error') { $_creatingInvite = false; $_inviteError = 'Failed to create invite link.' }"
data-attr:disabled="$_creatingInvite"
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:min-w-44"
>
<span data-show="!$_creatingInvite">{{ icons::user("h-4 w-4") }}</span>
<span data-show="$_creatingInvite" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span
>
New Invite
</button>
</div>
</div>
</section>
<!-- Data -->
<section class="rounded-lg border bg-surface p-5">
<div class="flex flex-col gap-4">
@ -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) => {

26
tests/cli/invites_cli.rs Normal file
View file

@ -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}"
);
}

View file

@ -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;

View file

@ -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]