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:
parent
22c0a30d38
commit
9cf8ce5d79
13 changed files with 397 additions and 2 deletions
11
README.md
11
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="<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
|
||||
|
|
|
|||
75
src/application/routes/api/auth/invites.rs
Normal file
75
src/application/routes/api/auth/invites.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub(crate) mod invites;
|
||||
pub(crate) mod tokens;
|
||||
pub(crate) mod webauthn;
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
34
src/infrastructure/client/invites.rs
Normal file
34
src/infrastructure/client/invites.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
26
src/presentation/cli/invites.rs
Normal file
26
src/presentation/cli/invites.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
26
tests/cli/invites_cli.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue