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
|
Open that URL, choose a display name, and register a passkey. This creates an account and
|
||||||
signs in automatically.
|
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
|
### Install from Git
|
||||||
|
|
||||||
To build and install from source, you'll need a working Rust toolchain:
|
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"
|
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.
|
Run `brewlog --help` for the full command reference.
|
||||||
|
|
||||||
## Configuration
|
## 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 tokens;
|
||||||
pub(crate) mod webauthn;
|
pub(crate) mod webauthn;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ pub(crate) mod system;
|
||||||
|
|
||||||
// Re-exports for backward compatibility
|
// Re-exports for backward compatibility
|
||||||
pub(crate) use analytics::stats;
|
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 coffee::{bags, brews, cafes, checkin, cups, gear, roasters, roasts, scan};
|
||||||
pub(crate) use system::{admin, backup, timeline};
|
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),
|
post(tokens::create_token).get(tokens::list_tokens),
|
||||||
)
|
)
|
||||||
.route("/tokens/{id}/revoke", post(tokens::revoke_token))
|
.route("/tokens/{id}/revoke", post(tokens::revoke_token))
|
||||||
|
.route("/invites", post(invites::create_invite))
|
||||||
.route("/passkeys", get(admin::list_passkeys))
|
.route("/passkeys", get(admin::list_passkeys))
|
||||||
.route(
|
.route(
|
||||||
"/passkeys/{id}",
|
"/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 cafes;
|
||||||
pub mod cups;
|
pub mod cups;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
|
pub mod invites;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
|
|
@ -62,6 +63,10 @@ impl BrewlogClient {
|
||||||
tokens::TokensClient::new(self)
|
tokens::TokensClient::new(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn invites(&self) -> invites::InvitesClient<'_> {
|
||||||
|
invites::InvitesClient::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn bags(&self) -> bags::BagsClient<'_> {
|
pub fn bags(&self) -> bags::BagsClient<'_> {
|
||||||
bags::BagsClient::new(self)
|
bags::BagsClient::new(self)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ use brewlog::application::{ServerConfig, serve};
|
||||||
use brewlog::infrastructure::backup::BackupData;
|
use brewlog::infrastructure::backup::BackupData;
|
||||||
use brewlog::infrastructure::client::BrewlogClient;
|
use brewlog::infrastructure::client::BrewlogClient;
|
||||||
use brewlog::presentation::cli::{
|
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 clap::Parser;
|
||||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
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)?;
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
||||||
tokens::run(&client, command).await
|
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 } => {
|
Commands::Timeline { command } => {
|
||||||
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
||||||
match command {
|
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 cafes;
|
||||||
pub mod cups;
|
pub mod cups;
|
||||||
pub mod gear;
|
pub mod gear;
|
||||||
|
pub mod invites;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
|
|
@ -21,6 +22,7 @@ use cafes::CafeCommands;
|
||||||
use clap::{Args, Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
use cups::CupCommands;
|
use cups::CupCommands;
|
||||||
use gear::GearCommands;
|
use gear::GearCommands;
|
||||||
|
use invites::InviteCommands;
|
||||||
use roasters::RoasterCommands;
|
use roasters::RoasterCommands;
|
||||||
use roasts::RoastCommands;
|
use roasts::RoastCommands;
|
||||||
use timeline::TimelineCommands;
|
use timeline::TimelineCommands;
|
||||||
|
|
@ -94,6 +96,12 @@ pub enum Commands {
|
||||||
command: TokenCommands,
|
command: TokenCommands,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Manage invite links for onboarding new users
|
||||||
|
Invite {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: InviteCommands,
|
||||||
|
},
|
||||||
|
|
||||||
/// Manage timeline events
|
/// Manage timeline events
|
||||||
Timeline {
|
Timeline {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,85 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Data -->
|
||||||
<section class="rounded-lg border bg-surface p-5">
|
<section class="rounded-lg border bg-surface p-5">
|
||||||
<div class="flex flex-col gap-4">
|
<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 ---
|
// --- Data management ---
|
||||||
|
|
||||||
const restoreFromFile = async (input) => {
|
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 cups_cli;
|
||||||
pub mod gear_cli;
|
pub mod gear_cli;
|
||||||
pub mod helpers;
|
pub mod helpers;
|
||||||
|
pub mod invites_cli;
|
||||||
pub mod roasters_cli;
|
pub mod roasters_cli;
|
||||||
pub mod roasts_cli;
|
pub mod roasts_cli;
|
||||||
pub mod test_macros;
|
pub mod test_macros;
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,109 @@ async fn test_read_endpoints_dont_require_authentication() {
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
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 ---
|
// --- Admin passkey endpoint tests ---
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue