From aa18c4bb0ac3e5d37ffb99e2d6f100d4d29dd272 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Sat, 24 Oct 2026 18:42:29 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Beta=20invite=20codes=20(=C2=A76.5.1):=20mi?= =?UTF-8?q?gration,=20redemption,=20console=20invites=20+=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/outer/migrations/0002_invite_code.sql | 20 + crates/outer/src/auth/invites.rs | 348 +++++++++++++ crates/outer/src/auth/mod.rs | 111 +++- crates/outer/src/auth/oauth.rs | 19 +- crates/outer/src/auth/pages.rs | 29 +- crates/outer/src/auth/tests.rs | 253 ++++++++- crates/outer/src/http/console.rs | 509 ++++++++++++++++++- crates/outer/src/mcp/error.rs | 2 +- crates/outer/src/mcp/registry.rs | 2 +- crates/outer/src/mcp/tools/account.rs | 5 + web/docs/index.mdx | 9 +- web/docs/reference/tools.md | 3 +- web/docs/sign-in.mdx | 12 +- web/pages/console/_islands/api.ts | 46 ++ web/pages/console/_islands/common.tsx | 14 +- web/pages/console/_islands/console.tsx | 233 ++++++++- web/pages/console/_islands/icons.tsx | 10 + web/pages/console/_islands/views.tsx | 450 ++++++++++++++++ 18 files changed, 2002 insertions(+), 73 deletions(-) create mode 100644 crates/outer/migrations/0002_invite_code.sql create mode 100644 crates/outer/src/auth/invites.rs diff --git a/crates/outer/migrations/0002_invite_code.sql b/crates/outer/migrations/0002_invite_code.sql new file mode 100644 index 0000000..277dc02 --- /dev/null +++ b/crates/outer/migrations/0002_invite_code.sql @@ -0,0 +1,20 @@ +-- Invite codes (spec §6.5.1): single-use admission codes plus the 3 +-- grant codes every new account gets to share. Stored as-is (shown again +-- to their owner); 40 bits plus the sign-up rate limits make guessing +-- pointless. camelCase like the Better Auth tables in 0001. +CREATE TABLE invite_code ( + code TEXT PRIMARY KEY, + createdBy TEXT REFERENCES user(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('admin', 'grant')), + note TEXT, + redeemedBy TEXT UNIQUE REFERENCES user(id) ON DELETE SET NULL, + redeemedAt INTEGER, + revokedAt INTEGER, + createdAt INTEGER NOT NULL +); +CREATE INDEX invite_code_createdBy ON invite_code (createdBy); + +-- The pending OTP row carries the invite code that gated admission, so +-- `verify_code` can consume it in the same transaction that creates the +-- account. NULL for existing/allowlisted/admin emails (ignored, §6.5.1). +ALTER TABLE verification ADD COLUMN inviteCode TEXT; diff --git a/crates/outer/src/auth/invites.rs b/crates/outer/src/auth/invites.rs new file mode 100644 index 0000000..e19542c --- /dev/null +++ b/crates/outer/src/auth/invites.rs @@ -0,0 +1,348 @@ +//! Invite codes (spec §6.5.1): `OUTER-XXXX-XXXX`, 8 Crockford base32 +//! characters (40 bits) from the OS CSPRNG, the same source the codebase +//! already uses for ids and secrets (`rand::random`). +//! +//! Every new account gets 3 `grant` codes to share (admins get none); +//! admins mint `admin` codes in bulk through the console. A code is valid +//! while it exists, is unredeemed and is unrevoked; redemption consumes it +//! in the same transaction that creates the account, so a code spent +//! between `sign_in` and `verify_code` refuses the second verifier. + +use sqlx::{Sqlite, SqlitePool, Transaction}; + +/// Codes a fresh account gets to share (`kind = 'grant'`). +pub const GRANTS_PER_USER: usize = 3; + +/// The most codes one admin generation may mint. +pub const MAX_ADMIN_BATCH: i64 = 50; + +/// Crockford base32, no I/L/O/U: unambiguous when read aloud. +const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// A fresh code: `OUTER-` + 4 chars + `-` + 4 chars, 40 bits of entropy. +pub fn new_invite_code() -> String { + let bytes: [u8; 5] = rand::random(); + let mut bits: u64 = 0; + for byte in bytes { + bits = (bits << 8) | u64::from(byte); + } + let mut body = String::with_capacity(8); + for shift in (0..8).rev() { + body.push(CROCKFORD[((bits >> (shift * 5)) & 31) as usize] as char); + } + format!("OUTER-{}-{}", &body[..4], &body[4..]) +} + +/// Normalize user input to the canonical `OUTER-XXXX-XXXX` form: trim, +/// uppercase, Crockford aliases (`O` → `0`, `I`/`L` → `1`), optional +/// `OUTER-` prefix, optional dashes. `None` when it cannot be a code. +pub fn normalize_invite_code(raw: &str) -> Option { + let upper = raw.trim().to_ascii_uppercase(); + let body = upper.strip_prefix("OUTER").unwrap_or(&upper); + let compact: String = body + .chars() + .filter(|c| *c != '-' && *c != ' ') + .map(|c| match c { + 'O' => '0', + 'I' | 'L' => '1', + c => c, + }) + .collect(); + if compact.len() != 8 || !compact.bytes().all(|b| CROCKFORD.contains(&b)) { + return None; + } + Some(format!("OUTER-{}-{}", &compact[..4], &compact[4..])) +} + +/// `true` while the code exists, is unredeemed and is unrevoked. +pub async fn is_valid(pool: &SqlitePool, code: &str) -> anyhow::Result { + let found: Option = sqlx::query_scalar( + "SELECT code FROM invite_code WHERE code = ? AND redeemedBy IS NULL AND revokedAt IS NULL", + ) + .bind(code) + .fetch_optional(pool) + .await?; + Ok(found.is_some()) +} + +/// Mint the 3 grant codes for a fresh account, inside the caller's account +/// creation transaction. A code collision (40 bits, near-impossible) retries +/// with a fresh code rather than failing sign-up. +pub async fn mint_grants( + tx: &mut Transaction<'_, Sqlite>, + user_id: &str, + now: i64, +) -> anyhow::Result<()> { + for _ in 0..GRANTS_PER_USER { + let mut minted = false; + for _ in 0..5 { + let code = new_invite_code(); + let inserted = sqlx::query( + "INSERT INTO invite_code (code, createdBy, kind, createdAt) VALUES (?, ?, 'grant', ?)", + ) + .bind(&code) + .bind(user_id) + .bind(now) + .execute(&mut **tx) + .await; + match inserted { + Ok(_) => { + minted = true; + break; + } + Err(err) + if err + .as_database_error() + .is_some_and(|db| db.is_unique_violation()) => + { + continue; + } + Err(err) => return Err(err.into()), + } + } + if !minted { + anyhow::bail!("could not mint a unique invite code"); + } + } + Ok(()) +} + +/// Mint `count` admin codes for `creator_id`. The caller validates the range. +pub async fn mint_admin( + pool: &SqlitePool, + creator_id: &str, + count: i64, + note: Option<&str>, + now: i64, +) -> anyhow::Result> { + let mut codes = Vec::with_capacity(count as usize); + for _ in 0..count { + // Collisions are near-impossible at 40 bits; retry a few times + // rather than failing the batch. + let mut minted: Option = None; + for _ in 0..5 { + let code = new_invite_code(); + let inserted = sqlx::query( + "INSERT INTO invite_code (code, createdBy, kind, note, createdAt) VALUES (?, ?, 'admin', ?, ?)", + ) + .bind(&code) + .bind(creator_id) + .bind(note) + .bind(now) + .execute(pool) + .await; + match inserted { + Ok(_) => { + minted = Some(code); + break; + } + Err(err) + if err + .as_database_error() + .is_some_and(|db| db.is_unique_violation()) => + { + continue; + } + Err(err) => return Err(err.into()), + } + } + match minted { + Some(code) => codes.push(code), + None => anyhow::bail!("could not mint a unique invite code"), + } + } + Ok(codes) +} + +/// Consume a code for a fresh account: the single statement that makes the +/// double-spend race safe. `true` only when this caller won the code. +pub async fn consume( + tx: &mut Transaction<'_, Sqlite>, + code: &str, + user_id: &str, + now: i64, +) -> anyhow::Result { + let rows = sqlx::query( + "UPDATE invite_code SET redeemedBy = ?, redeemedAt = ? \ + WHERE code = ? AND redeemedBy IS NULL AND revokedAt IS NULL", + ) + .bind(user_id) + .bind(now) + .bind(code) + .execute(&mut **tx) + .await? + .rows_affected(); + Ok(rows == 1) +} + +/// One code row with the creator/redeemer emails the console shows. +#[derive(Debug, sqlx::FromRow)] +pub struct InviteRow { + pub code: String, + pub kind: String, + pub note: Option, + pub created_at: i64, + pub redeemed_at: Option, + pub revoked_at: Option, + pub created_by_email: Option, + pub redeemed_by_email: Option, +} + +/// The caller's own codes, newest first. +pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> anyhow::Result> { + sqlx::query_as( + "SELECT i.code, i.kind, i.note, i.createdAt AS created_at, \ + i.redeemedAt AS redeemed_at, i.revokedAt AS revoked_at, \ + u.email AS created_by_email, r.email AS redeemed_by_email \ + FROM invite_code i \ + LEFT JOIN user u ON u.id = i.createdBy \ + LEFT JOIN user r ON r.id = i.redeemedBy \ + WHERE i.createdBy = ? \ + ORDER BY i.createdAt DESC, i.code DESC", + ) + .bind(user_id) + .fetch_all(pool) + .await + .map_err(Into::into) +} + +/// Every code, newest first, paged by the opaque `:` cursor. +pub async fn list_all( + pool: &SqlitePool, + limit: i64, + before: Option<(i64, String)>, +) -> anyhow::Result> { + let rows = match before { + Some((at, code)) => { + sqlx::query_as( + "SELECT i.code, i.kind, i.note, i.createdAt AS created_at, \ + i.redeemedAt AS redeemed_at, i.revokedAt AS revoked_at, \ + u.email AS created_by_email, r.email AS redeemed_by_email \ + FROM invite_code i \ + LEFT JOIN user u ON u.id = i.createdBy \ + LEFT JOIN user r ON r.id = i.redeemedBy \ + WHERE (i.createdAt < ? OR (i.createdAt = ? AND i.code < ?)) \ + ORDER BY i.createdAt DESC, i.code DESC LIMIT ?", + ) + .bind(at) + .bind(at) + .bind(code) + .bind(limit) + .fetch_all(pool) + .await? + } + None => { + sqlx::query_as( + "SELECT i.code, i.kind, i.note, i.createdAt AS created_at, \ + i.redeemedAt AS redeemed_at, i.revokedAt AS revoked_at, \ + u.email AS created_by_email, r.email AS redeemed_by_email \ + FROM invite_code i \ + LEFT JOIN user u ON u.id = i.createdBy \ + LEFT JOIN user r ON r.id = i.redeemedBy \ + ORDER BY i.createdAt DESC, i.code DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(pool) + .await? + } + }; + Ok(rows) +} + +/// Revoking a code only un-admits unused ones. +#[derive(Debug, PartialEq, Eq)] +pub enum RevokeOutcome { + Revoked, + AlreadyRevoked, + Missing, + Redeemed, +} + +pub async fn revoke(pool: &SqlitePool, code: &str, now: i64) -> anyhow::Result { + let row: Option<(Option, Option)> = + sqlx::query_as("SELECT redeemedBy, revokedAt FROM invite_code WHERE code = ?") + .bind(code) + .fetch_optional(pool) + .await?; + let Some((redeemed_by, revoked_at)) = row else { + return Ok(RevokeOutcome::Missing); + }; + if redeemed_by.is_some() { + return Ok(RevokeOutcome::Redeemed); + } + if revoked_at.is_some() { + return Ok(RevokeOutcome::AlreadyRevoked); + } + sqlx::query("UPDATE invite_code SET revokedAt = ? WHERE code = ?") + .bind(now) + .bind(code) + .execute(pool) + .await?; + Ok(RevokeOutcome::Revoked) +} + +/// One waitlist row for the admin table. +#[derive(Debug, serde::Serialize, sqlx::FromRow)] +pub struct WaitlistRow { + pub email: String, + pub first_seen: i64, + pub attempts: i64, +} + +pub async fn waitlist(pool: &SqlitePool, limit: i64) -> anyhow::Result> { + sqlx::query_as( + "SELECT email, first_seen, attempts FROM waitlist \ + ORDER BY first_seen DESC LIMIT ?", + ) + .bind(limit) + .fetch_all(pool) + .await + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn codes_are_outer_prefixed_crockford() { + let mut seen = HashSet::new(); + for _ in 0..200 { + let code = new_invite_code(); + assert!(code.starts_with("OUTER-"), "{code}"); + assert_eq!(code.len(), 15, "{code}"); + assert!(seen.insert(code.clone()), "duplicate code {code}"); + let body: String = code.chars().filter(|c| *c != '-').skip(5).collect(); + assert_eq!(body.len(), 8); + assert!(body.bytes().all(|b| CROCKFORD.contains(&b)), "{code}"); + } + } + + #[test] + fn normalization_accepts_prefix_dashes_aliases() { + // Acceptance: these three inputs normalize alike (O→0). + assert_eq!( + normalize_invite_code("outer-7k3m-q9xd"), + Some("OUTER-7K3M-Q9XD".to_string()) + ); + assert_eq!( + normalize_invite_code("7K3MQ9XD"), + Some("OUTER-7K3M-Q9XD".to_string()) + ); + assert_eq!( + normalize_invite_code("OUTER-7K3M-Q9XO"), + Some("OUTER-7K3M-Q9X0".to_string()) + ); + // I/L aliases, whitespace, lowercase, dashes optional. + assert_eq!( + normalize_invite_code(" outer-il0o-1234 "), + Some("OUTER-1100-1234".to_string()) + ); + // Reject: wrong length, non-Crockford (U, and symbols). + assert_eq!(normalize_invite_code("OUTER-SHORT"), None); + assert_eq!(normalize_invite_code("OUTER-7K3M-Q9XU"), None); + assert_eq!(normalize_invite_code("not a code!!"), None); + assert_eq!(normalize_invite_code(""), None); + } +} diff --git a/crates/outer/src/auth/mod.rs b/crates/outer/src/auth/mod.rs index bd86e8f..d8cf8bb 100644 --- a/crates/outer/src/auth/mod.rs +++ b/crates/outer/src/auth/mod.rs @@ -7,6 +7,7 @@ pub mod cimd; pub mod email; +pub mod invites; pub mod limits; pub mod oauth; pub mod pages; @@ -201,16 +202,22 @@ fn otp_identifier(email: &str) -> String { } /// Closed-beta gate: open signup, admin emails and allowlisted emails pass; -/// anyone else lands on the waitlist (counted) with the exact closed-beta -/// user message. Idempotent: repeat sends bump the waitlist count. +/// anyone else needs a valid invite code (§6.5.1) or lands on the waitlist +/// (counted) with the exact closed-beta user message. Idempotent: repeat +/// sends bump the waitlist count. +/// +/// Returns the canonical invite code to carry with the pending verification +/// when the code is what admits the email; `None` admits without consuming +/// anything (existing/allowlisted/admin emails ignore the code, §6.5.1). async fn check_beta( pool: &SqlitePool, cfg: &AuthConfig, email: &str, + invite_code: Option<&str>, now: i64, -) -> Result<(), AuthError> { +) -> Result, AuthError> { if cfg.signup_open || is_admin(cfg, email) { - return Ok(()); + return Ok(None); } let allowed: Option<(String,)> = sqlx::query_as("SELECT email FROM beta_allowlist WHERE email = ?") @@ -219,7 +226,17 @@ async fn check_beta( .await .map_err(|err| AuthError::new("db", format!("allowlist lookup failed: {err}")))?; if allowed.is_some() { - return Ok(()); + return Ok(None); + } + if let Some(raw) = invite_code { + if let Some(canonical) = invites::normalize_invite_code(raw) { + let valid = invites::is_valid(pool, &canonical) + .await + .map_err(|err| AuthError::new("db", format!("invite lookup failed: {err}")))?; + if valid { + return Ok(Some(canonical)); + } + } } sqlx::query( "INSERT INTO waitlist (email, first_seen, attempts) VALUES (?, ?, 1) \ @@ -233,17 +250,23 @@ async fn check_beta( Err(AuthError { code: "closed_beta", message: format!("{email} is not on the beta allowlist"), - user_message: Some("Outer is in closed beta. You're on the waitlist.".to_string()), + user_message: Some( + "Outer is in closed beta. Ask someone on Outer for an invite code, or you're on the waitlist." + .to_string(), + ), retry_after_s: None, }) } /// Shared send path for `otp_sign_in` and the OAuth email step: same codes, -/// same 10-minute expiry, same 5/h-per-email and 20/h-per-IP limits. +/// same 10-minute expiry, same 5/h-per-email and 20/h-per-IP limits. The +/// optional invite code (§6.5.1) only matters for a new email during the +/// closed beta: existing accounts, allowlisted emails and admins ignore it. pub async fn send_code( pool: &SqlitePool, cfg: &AuthConfig, email: &str, + invite_code: Option<&str>, client_ip: Option<&str>, ) -> Result { const EXPIRES_IN_S: u64 = 600; @@ -255,11 +278,10 @@ pub async fn send_code( format!("{email:?} is not an email address"), )); } - check_beta(pool, cfg, &email, now).await?; - if let Some(account) = account_by_email(pool, &email) + let existing = account_by_email(pool, &email) .await - .map_err(|err| AuthError::new("db", format!("account lookup failed: {err}")))? - { + .map_err(|err| AuthError::new("db", format!("account lookup failed: {err}")))?; + if let Some(account) = &existing { if account.banned { return Err(AuthError::new( "forbidden", @@ -267,6 +289,12 @@ pub async fn send_code( )); } } + // Returning users sign straight back in: the beta gate only admits new + // emails (invite joiners are never allowlisted, §6.5.1). + let carried = match &existing { + Some(_) => None, + None => check_beta(pool, cfg, &email, invite_code, now).await?, + }; match limits::check_and_record( pool, &format!("email:{email}"), @@ -292,10 +320,11 @@ pub async fn send_code( } let code = tokens::new_otp_code(); sqlx::query( - "INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt) \ - VALUES (?, ?, ?, ?, ?, ?) \ + "INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt, inviteCode) \ + VALUES (?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(identifier) DO UPDATE SET value = excluded.value, \ - expiresAt = excluded.expiresAt, attempts = 0, updatedAt = excluded.updatedAt", + expiresAt = excluded.expiresAt, attempts = 0, updatedAt = excluded.updatedAt, \ + inviteCode = excluded.inviteCode", ) .bind(tokens::new_id()) .bind(otp_identifier(&email)) @@ -303,6 +332,7 @@ pub async fn send_code( .bind(now + (EXPIRES_IN_S as i64) * 1000) .bind(now) .bind(now) + .bind(carried) .execute(pool) .await .map_err(|err| AuthError::new("db", format!("code store failed: {err}")))?; @@ -343,13 +373,14 @@ pub async fn verify_code_core( let now = now_ms(); let email = normalize_email(email); let identifier = otp_identifier(&email); - let row: Option<(String, i64, i64)> = - sqlx::query_as("SELECT value, expiresAt, attempts FROM verification WHERE identifier = ?") - .bind(&identifier) - .fetch_optional(pool) - .await - .map_err(db)?; - let Some((code_hash, expires_at, attempts)) = row else { + let row: Option<(String, i64, i64, Option)> = sqlx::query_as( + "SELECT value, expiresAt, attempts, inviteCode FROM verification WHERE identifier = ?", + ) + .bind(&identifier) + .fetch_optional(pool) + .await + .map_err(db)?; + let Some((code_hash, expires_at, attempts, carried_invite)) = row else { return Err(AuthError::new( "invalid_code", "no pending code for this email".to_string(), @@ -411,6 +442,8 @@ pub async fn verify_code_core( .await .map_err(|err| AuthError::new("db", format!("{err}")))? { + // Existing accounts ignore the code: it admitted nobody, so it + // burns nothing (§6.5.1). Some(account) => account, None => { if let Some(ip) = client_ip { @@ -428,6 +461,11 @@ pub async fn verify_code_core( return Err(AuthError::rate_limited(retry_after_s)); } } + // One transaction: insert the account, consume the carried + // invite, mint the 3 grant codes (admins get none). The consume + // is conditional, so a code spent between `sign_in` and verify + // refuses here and creates nothing. + let mut tx = pool.begin().await.map_err(db)?; let id = tokens::new_id(); sqlx::query( "INSERT INTO user (id, email, emailVerified, lastActiveAt, createdAt, updatedAt) \ @@ -438,9 +476,29 @@ pub async fn verify_code_core( .bind(now) .bind(now) .bind(now) - .execute(pool) + .execute(&mut *tx) .await .map_err(db)?; + if let Some(code) = carried_invite.as_deref() { + let won = invites::consume(&mut tx, code, &id, now) + .await + .map_err(|err| AuthError::new("db", format!("{err}")))?; + if !won { + tx.rollback().await.map_err(db)?; + return Err(AuthError { + code: "invite_used", + message: format!("invite code {code} was just used"), + user_message: Some("That invite code was just used.".to_string()), + retry_after_s: None, + }); + } + } + if !is_admin(cfg, &email) { + invites::mint_grants(&mut tx, &id, now) + .await + .map_err(|err| AuthError::new("db", format!("{err}")))?; + } + tx.commit().await.map_err(db)?; AccountRow { id, email: email.clone(), @@ -492,17 +550,18 @@ pub async fn record_terms_acceptance( Ok(()) } -/// `sign_in` tool (§6.2): normalize, beta allowlist/waitlist (§6.5), limits -/// (5/h per email, 20/h per IP, §14.3), store sha256(code) 10 min / 5 -/// attempts, send. +/// `sign_in` tool (§6.2): normalize, beta allowlist/waitlist or invite code +/// (§6.5.1), limits (5/h per email, 20/h per IP, §14.3), store sha256(code) +/// 10 min / 5 attempts, send. pub async fn otp_sign_in( pool: &SqlitePool, cfg: &AuthConfig, email: &str, + invite_code: Option<&str>, client_ip: Option<&str>, ) -> Result { Ok(SignInSent { - expires_in_s: send_code(pool, cfg, email, client_ip).await?, + expires_in_s: send_code(pool, cfg, email, invite_code, client_ip).await?, }) } diff --git a/crates/outer/src/auth/oauth.rs b/crates/outer/src/auth/oauth.rs index f8d440e..d15e71c 100644 --- a/crates/outer/src/auth/oauth.rs +++ b/crates/outer/src/auth/oauth.rs @@ -367,7 +367,7 @@ async fn authorize_get( } html( StatusCode::OK, - pages::email_page(&client, &id, &csrf, None), + pages::email_page(&client, &id, &csrf, None, false, ""), None, ) } @@ -434,7 +434,17 @@ async fn authorize_post( match step.as_str() { "email" => { let email = form.get("email").cloned().unwrap_or_default(); - match super::send_code(&state.pool, &state.config, &email, ip.as_deref()).await { + let invite_code = form.get("invite_code").cloned().unwrap_or_default(); + let invite_value = invite_code.trim().to_string(); + match super::send_code( + &state.pool, + &state.config, + &email, + (!invite_value.is_empty()).then_some(invite_value.as_str()), + ip.as_deref(), + ) + .await + { Ok(_) => html( StatusCode::OK, pages::code_page( @@ -451,6 +461,9 @@ async fn authorize_post( } else { StatusCode::BAD_REQUEST }; + // A closed-beta refusal opens the invite field so the + // user can retry with a code in one step. + let invite_open = err.code == "closed_beta"; html( status, pages::email_page( @@ -458,6 +471,8 @@ async fn authorize_post( &pending.id, &pending.csrf, Some(pages::friendly_error(err.code)), + invite_open, + &invite_value, ), None, ) diff --git a/crates/outer/src/auth/pages.rs b/crates/outer/src/auth/pages.rs index d971e27..3791d6f 100644 --- a/crates/outer/src/auth/pages.rs +++ b/crates/outer/src/auth/pages.rs @@ -53,7 +53,18 @@ fn hidden(request_id: &str, csrf: &str, step: &str) -> Markup { } } -pub fn email_page(client: &ClientDoc, request_id: &str, csrf: &str, error: Option<&str>) -> Markup { +/// The email-first sign-in step. The invite field stays collapsed behind +/// "Have an invite code?" (a no-JS `
`), opening automatically when +/// the server refuses with the closed-beta message. `invite_value` preserves +/// what the user typed across that refusal. +pub fn email_page( + client: &ClientDoc, + request_id: &str, + csrf: &str, + error: Option<&str>, + invite_open: bool, + invite_value: &str, +) -> Markup { layout( "Sign in", html! { @@ -67,7 +78,18 @@ pub fn email_page(client: &ClientDoc, request_id: &str, csrf: &str, error: Optio br; input type="email" name="email" id="email" required autocomplete="email" style="width:100%;font-size:1rem;padding:.5rem;" {} - br; br; + br; + details open[invite_open] { + summary { "Have an invite code?" } + p style="font-size:.9rem;" { "Outer is in closed beta. New here without an allowlisted email? Paste the code someone on Outer shared with you." } + label for="invite_code" { "Invite code (optional)" } + br; + input type="text" name="invite_code" id="invite_code" value=(invite_value) + autocomplete="off" spellcheck="false" + placeholder="OUTER-XXXX-XXXX" + style="width:100%;font-size:1rem;padding:.5rem;" {} + } + br; button type="submit" style="font-size:1rem;padding:.5rem 1rem;" { "Send code" } } }, @@ -191,11 +213,12 @@ pub fn error_page(title: &str, message: &str) -> Markup { /// the email page; anything else becomes a 400 error page. pub fn friendly_error(code: &str) -> &str { match code { - "closed_beta" => "Outer is in closed beta. You're on the waitlist.", + "closed_beta" => "Outer is in closed beta. Ask someone on Outer for an invite code, or you're on the waitlist.", "rate_limited" => "Too many attempts. Try again shortly.", "invalid_email" => "That doesn't look like an email address.", "email_retry" => "Outer couldn't send the code. Try again shortly.", "email_failed" => "Outer couldn't send the code to that address.", + "invite_used" => "That invite code was just used.", _ => "Something went wrong signing in. Start over from the app.", } } diff --git a/crates/outer/src/auth/tests.rs b/crates/outer/src/auth/tests.rs index 1f26eef..a1fced5 100644 --- a/crates/outer/src/auth/tests.rs +++ b/crates/outer/src/auth/tests.rs @@ -1282,7 +1282,7 @@ async fn otp_attempts_expiry_and_limits() { }; // Five wrong codes burn the row. - auth::otp_sign_in(&pool, &state.config, "ana@example.com", None) + auth::otp_sign_in(&pool, &state.config, "ana@example.com", None, None) .await .unwrap(); for _ in 0..5 { @@ -1317,7 +1317,7 @@ async fn otp_attempts_expiry_and_limits() { assert_eq!(err.code, "invalid_code", "burned row reads as unknown"); // Expired codes are rejected and cleared. - auth::otp_sign_in(&pool, &state.config, "ana@example.com", None) + auth::otp_sign_in(&pool, &state.config, "ana@example.com", None, None) .await .unwrap(); let code = captured(&codes, "ana@example.com"); @@ -1340,14 +1340,14 @@ async fn otp_attempts_expiry_and_limits() { // Five sends per hour per email. for _ in 0..4 { - auth::otp_sign_in(&pool, &state.config, "limited@example.com", None) + auth::otp_sign_in(&pool, &state.config, "limited@example.com", None, None) .await .unwrap(); } - auth::otp_sign_in(&pool, &state.config, "limited@example.com", None) + auth::otp_sign_in(&pool, &state.config, "limited@example.com", None, None) .await .unwrap(); - let err = auth::otp_sign_in(&pool, &state.config, "limited@example.com", None) + let err = auth::otp_sign_in(&pool, &state.config, "limited@example.com", None, None) .await .unwrap_err(); assert_eq!(err.code, "rate_limited"); @@ -1359,6 +1359,7 @@ async fn otp_attempts_expiry_and_limits() { &pool, &state.config, &format!("user{index}@example.com"), + None, Some("10.0.0.1"), ) .await @@ -1368,6 +1369,7 @@ async fn otp_attempts_expiry_and_limits() { &pool, &state.config, "one-more@example.com", + None, Some("10.0.0.1"), ) .await @@ -1385,13 +1387,13 @@ async fn closed_beta_waitlists_by_default() { config, }; - let err = auth::otp_sign_in(&pool, &state.config, "new@example.com", None) + let err = auth::otp_sign_in(&pool, &state.config, "new@example.com", None, None) .await .unwrap_err(); assert_eq!(err.code, "closed_beta"); assert_eq!( err.user_message.as_deref(), - Some("Outer is in closed beta. You're on the waitlist.") + Some("Outer is in closed beta. Ask someone on Outer for an invite code, or you're on the waitlist.") ); let (attempts,): (i64,) = sqlx::query_as("SELECT attempts FROM waitlist WHERE email = 'new@example.com'") @@ -1399,7 +1401,7 @@ async fn closed_beta_waitlists_by_default() { .await .unwrap(); assert_eq!(attempts, 1); - let _ = auth::otp_sign_in(&pool, &state.config, "new@example.com", None) + let _ = auth::otp_sign_in(&pool, &state.config, "new@example.com", None, None) .await .unwrap_err(); let (attempts,): (i64,) = @@ -1414,12 +1416,241 @@ async fn closed_beta_waitlists_by_default() { .execute(&pool) .await .unwrap(); - auth::otp_sign_in(&pool, &state.config, "friend@example.com", None) + auth::otp_sign_in(&pool, &state.config, "friend@example.com", None, None) + .await + .unwrap(); + auth::otp_sign_in(&pool, &state.config, "owner@example.com", None, None) + .await + .unwrap(); +} + +/// A fresh account mints exactly 3 grant codes; an admin account mints none. +#[tokio::test] +async fn fresh_account_gets_three_grants_admin_gets_none() { + let pool = test_pool().await; + let (config, codes) = test_config(); + + for email in ["ana@example.com", "owner@example.com"] { + auth::otp_sign_in(&pool, &config, email, None, None) + .await + .unwrap(); + } + for email in ["ana@example.com", "owner@example.com"] { + let code = captured(&codes, email); + auth::otp_verify(&pool, &config, email, &code, true, None, None) + .await + .unwrap(); + } + let (ana_grants,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM invite_code WHERE kind = 'grant' AND createdBy = \ + (SELECT id FROM user WHERE email = 'ana@example.com')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(ana_grants, 3); + let (owner_grants,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM invite_code WHERE createdBy = \ + (SELECT id FROM user WHERE email = 'owner@example.com')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(owner_grants, 0, "admins get no automatic codes"); +} + +/// The full invite round trip: a valid code admits a new email, verify +/// consumes the code and the new account holds 3 fresh grants. +#[tokio::test] +async fn invite_code_admits_and_is_consumed_on_verify() { + let pool = test_pool().await; + let (mut config, codes) = test_config(); + config.signup_open = false; + + let granter = "granter@example.com"; + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('granter', ?, 0, 0, 0)") + .bind(granter) + .execute(&pool) + .await + .unwrap(); + let made = auth::invites::mint_admin(&pool, "granter", 1, None, 0) + .await + .unwrap(); + let invite = made[0].clone(); + + // The code normalizes (lowercase, no prefix or dashes). + let loose = invite + .strip_prefix("OUTER-") + .unwrap() + .replace('-', "") + .to_lowercase(); + auth::otp_sign_in(&pool, &config, "joined@example.com", Some(&loose), None) + .await + .unwrap(); + let otp = captured(&codes, "joined@example.com"); + auth::otp_verify(&pool, &config, "joined@example.com", &otp, true, None, None) + .await + .unwrap(); + + let (redeemed_by,): (Option,) = + sqlx::query_as("SELECT redeemedBy FROM invite_code WHERE code = ?") + .bind(&invite) + .fetch_one(&pool) + .await + .unwrap(); + let (joined_id,): (String,) = + sqlx::query_as("SELECT id FROM user WHERE email = 'joined@example.com'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(redeemed_by, Some(joined_id.clone())); + let (grants,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM invite_code WHERE kind = 'grant' AND createdBy = ?") + .bind(&joined_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(grants, 3, "invite joiners get their own 3 codes"); +} + +/// Invalid, redeemed and revoked codes refuse with the waitlist path and +/// send no OTP. +#[tokio::test] +async fn bad_invite_codes_waitlist_without_otp() { + let pool = test_pool().await; + let (mut config, codes) = test_config(); + config.signup_open = false; + + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'a@example.com', 0, 0, 0)") + .execute(&pool) + .await + .unwrap(); + let made = auth::invites::mint_admin(&pool, "a1", 3, None, 0) + .await + .unwrap(); + // Redeem one via a first joiner, revoke another. + auth::otp_sign_in(&pool, &config, "first@example.com", Some(&made[0]), None) + .await + .unwrap(); + let otp = captured(&codes, "first@example.com"); + auth::otp_verify(&pool, &config, "first@example.com", &otp, true, None, None) + .await + .unwrap(); + auth::invites::revoke(&pool, &made[1], 1).await.unwrap(); + + for (email, code) in [ + ("bad1@example.com", "OUTER-N0PE-XXXX"), + ("bad2@example.com", &made[0]), + ("bad3@example.com", &made[1]), + ] { + let err = auth::otp_sign_in(&pool, &config, email, Some(code), None) + .await + .unwrap_err(); + assert_eq!(err.code, "closed_beta", "{email} with {code}"); + assert!( + !codes.lock().unwrap().contains_key(email), + "no OTP is sent for {email}" + ); + let (attempts,): (i64,) = sqlx::query_as("SELECT attempts FROM waitlist WHERE email = ?") + .bind(email) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(attempts, 1); + } +} + +/// Two emails hold the same code: the first verify wins, the second is +/// refused and creates no account. +#[tokio::test] +async fn invite_double_spend_second_verify_refused() { + let pool = test_pool().await; + let (mut config, codes) = test_config(); + config.signup_open = false; + + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'a@example.com', 0, 0, 0)") + .execute(&pool) + .await + .unwrap(); + let made = auth::invites::mint_admin(&pool, "a1", 1, None, 0) + .await + .unwrap(); + + for email in ["racer1@example.com", "racer2@example.com"] { + auth::otp_sign_in(&pool, &config, email, Some(&made[0]), None) + .await + .unwrap(); + } + let first = captured(&codes, "racer1@example.com"); + auth::otp_verify( + &pool, + &config, + "racer1@example.com", + &first, + true, + None, + None, + ) + .await + .unwrap(); + let second = captured(&codes, "racer2@example.com"); + let err = auth::otp_verify( + &pool, + &config, + "racer2@example.com", + &second, + true, + None, + None, + ) + .await + .unwrap_err(); + assert_eq!(err.code, "invite_used"); + assert_eq!( + err.user_message.as_deref(), + Some("That invite code was just used.") + ); + let (users,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM user WHERE email = 'racer2@example.com'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(users, 0, "the losing verify creates no account"); +} + +/// An allowlisted email that offers a code is admitted without burning it. +#[tokio::test] +async fn allowlisted_email_ignores_invite_code() { + let pool = test_pool().await; + let (mut config, codes) = test_config(); + config.signup_open = false; + + sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('a1', 'a@example.com', 0, 0, 0)") + .execute(&pool) + .await + .unwrap(); + let made = auth::invites::mint_admin(&pool, "a1", 1, None, 0) + .await + .unwrap(); + sqlx::query("INSERT INTO beta_allowlist (email, added_at) VALUES ('friend@example.com', 0)") + .execute(&pool) + .await + .unwrap(); + + auth::otp_sign_in(&pool, &config, "friend@example.com", Some(&made[0]), None) .await .unwrap(); - auth::otp_sign_in(&pool, &state.config, "owner@example.com", None) + let otp = captured(&codes, "friend@example.com"); + auth::otp_verify(&pool, &config, "friend@example.com", &otp, true, None, None) .await .unwrap(); + let (redeemed_by,): (Option,) = + sqlx::query_as("SELECT redeemedBy FROM invite_code WHERE code = ?") + .bind(&made[0]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(redeemed_by, None, "allowlisted sign-in burns no code"); } #[tokio::test] @@ -1431,7 +1662,7 @@ async fn api_keys_create_list_revoke_and_resolve() { config, }; - auth::otp_sign_in(&pool, &state.config, "ana@example.com", None) + auth::otp_sign_in(&pool, &state.config, "ana@example.com", None, None) .await .unwrap(); let code = captured(&codes, "ana@example.com"); diff --git a/crates/outer/src/http/console.rs b/crates/outer/src/http/console.rs index 8bd8dc4..41fbe1c 100644 --- a/crates/outer/src/http/console.rs +++ b/crates/outer/src/http/console.rs @@ -44,6 +44,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Form, Router}; use maud::{html, Markup}; +use serde::Deserialize; use serde_json::json; use crate::auth::{AccountRow, AuthState}; @@ -55,6 +56,16 @@ pub fn router(state: super::AppState) -> Router { Router::new() .route("/console/sign-in", get(sign_in_get).post(sign_in_post)) .route("/api/console/me", get(api_me)) + .route("/api/console/invites", get(api_invites)) + .route( + "/api/console/admin/invites", + get(api_admin_invites).post(api_admin_create_invites), + ) + .route( + "/api/console/admin/invites/{code}/revoke", + post(api_admin_revoke_invite), + ) + .route("/api/console/admin/waitlist", get(api_admin_waitlist)) .route("/api/console/apps", get(api_apps)) .route("/api/console/apps/{slug}", get(api_app)) .route("/api/console/apps/{slug}/actions", get(api_actions)) @@ -147,17 +158,227 @@ async fn api_me(State(state): State, headers: HeaderMap) -> Res let plan = plan_of(&mcp.pool, &account.id) .await .unwrap_or_else(|_| "free".into()); + let is_admin = crate::auth::is_admin(&mcp.auth.config, &account.email); ( StatusCode::OK, [ ("cache-control", "no-store"), ("content-type", "application/json"), ], - json!({"email": account.email, "plan": plan}).to_string(), + json!({"email": account.email, "plan": plan, "is_admin": is_admin}).to_string(), ) .into_response() } +/// The `guard` plus the admin check: non-admins get 403, decided by the +/// `OUTER_ADMIN_EMAILS` config like everywhere else. +async fn admin_guard(auth: &AuthState, headers: &HeaderMap) -> Result> { + let account = guard(auth, headers).await?; + if !crate::auth::is_admin(&auth.config, &account.email) { + return Err(nostore_json(StatusCode::FORBIDDEN, "forbidden", "admin only").into()); + } + Ok(account) +} + +fn invite_json(row: &crate::auth::invites::InviteRow) -> serde_json::Value { + json!({ + "code": row.code, + "kind": row.kind, + "note": row.note, + "createdAt": row.created_at, + "redeemedAt": row.redeemed_at, + "redeemedByEmail": row.redeemed_by_email, + "revokedAt": row.revoked_at, + }) +} + +fn admin_invite_json(row: &crate::auth::invites::InviteRow) -> serde_json::Value { + let mut v = invite_json(row); + v["createdByEmail"] = json!(row.created_by_email); + v +} + +/// `GET /api/console/invites`: the caller's own codes, newest first. +async fn api_invites(State(state): State, headers: HeaderMap) -> Response { + let mcp = &state.mcp; + let account = match guard(&mcp.auth, &headers).await { + Ok(account) => account, + Err(res) => return *res, + }; + match crate::auth::invites::list_for_user(&mcp.pool, &account.id).await { + Ok(rows) => ok_json(json!({"codes": rows.iter().map(invite_json).collect::>()})), + Err(_) => nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "invites are unavailable right now", + ), + } +} + +/// `GET /api/console/admin/invites?limit=&before=`: every code, newest +/// first. `before` is the opaque `:` cursor of the last +/// row on the previous page. +async fn api_admin_invites( + State(state): State, + headers: HeaderMap, + Query(query): Query>, +) -> Response { + let mcp = &state.mcp; + if let Err(res) = admin_guard(&mcp.auth, &headers).await { + return *res; + } + let limit = query + .get("limit") + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(50) + .clamp(1, 100); + let before = match query.get("before") { + None => None, + Some(raw) => match raw.split_once(':') { + Some((at, code)) if !code.is_empty() => match at.parse::() { + Ok(at) => Some((at, code.to_string())), + Err(_) => { + return nostore_json( + StatusCode::BAD_REQUEST, + "invalid_cursor", + "before must be :", + ); + } + }, + _ => { + return nostore_json( + StatusCode::BAD_REQUEST, + "invalid_cursor", + "before must be :", + ); + } + }, + }; + match crate::auth::invites::list_all(&mcp.pool, limit + 1, before).await { + Ok(rows) => { + let (page, next) = if rows.len() as i64 > limit { + let next = rows + .get(limit as usize - 1) + .map(|row| format!("{}:{}", row.created_at, row.code)); + (&rows[..limit as usize], next) + } else { + (&rows[..], None) + }; + let mut body = json!({"codes": page.iter().map(admin_invite_json).collect::>()}); + if let Some(next) = next { + body["next"] = json!(next); + } + ok_json(body) + } + Err(_) => nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "invites are unavailable right now", + ), + } +} + +#[derive(Debug, Deserialize)] +struct CreateInvites { + count: i64, + note: Option, +} + +/// `POST /api/console/admin/invites {count 1..50, note?}`: mint admin codes. +async fn api_admin_create_invites( + State(state): State, + headers: HeaderMap, + body: axum::Json, +) -> Response { + let mcp = &state.mcp; + let account = match admin_guard(&mcp.auth, &headers).await { + Ok(account) => account, + Err(res) => return *res, + }; + if !(1..=crate::auth::invites::MAX_ADMIN_BATCH).contains(&body.count) { + return nostore_json( + StatusCode::BAD_REQUEST, + "invalid_count", + "count must be between 1 and 50", + ); + } + let note = body + .note + .as_deref() + .map(str::trim) + .filter(|note| !note.is_empty()) + .map(|note| note.chars().take(200).collect::()); + let now = crate::auth::now_ms(); + match crate::auth::invites::mint_admin(&mcp.pool, &account.id, body.count, note.as_deref(), now) + .await + { + Ok(codes) => ok_json(json!({"codes": codes})), + Err(_) => nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "invites are unavailable right now", + ), + } +} + +/// `POST /api/console/admin/invites/{code}/revoke`: revoke an unused code. +/// Redeemed codes refuse; unknown codes 404. +async fn api_admin_revoke_invite( + State(state): State, + headers: HeaderMap, + Path(code): Path, +) -> Response { + let mcp = &state.mcp; + if let Err(res) = admin_guard(&mcp.auth, &headers).await { + return *res; + } + let code = crate::auth::invites::normalize_invite_code(&code).unwrap_or(code); + match crate::auth::invites::revoke(&mcp.pool, &code, crate::auth::now_ms()).await { + Ok(crate::auth::invites::RevokeOutcome::Missing) => { + nostore_json(StatusCode::NOT_FOUND, "not_found", "no such invite code") + } + Ok(crate::auth::invites::RevokeOutcome::Redeemed) => nostore_json( + StatusCode::BAD_REQUEST, + "already_used", + "that code was already used and cannot be revoked", + ), + Ok( + crate::auth::invites::RevokeOutcome::Revoked + | crate::auth::invites::RevokeOutcome::AlreadyRevoked, + ) => ok_json(json!({"revoked": true})), + Err(_) => nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "invites are unavailable right now", + ), + } +} + +/// `GET /api/console/admin/waitlist?limit=`: the waitlist, newest first. +async fn api_admin_waitlist( + State(state): State, + headers: HeaderMap, + Query(query): Query>, +) -> Response { + let mcp = &state.mcp; + if let Err(res) = admin_guard(&mcp.auth, &headers).await { + return *res; + } + let limit = query + .get("limit") + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(100) + .clamp(1, 500); + match crate::auth::invites::waitlist(&mcp.pool, limit).await { + Ok(entries) => ok_json(json!({"entries": entries})), + Err(_) => nostore_json( + StatusCode::INTERNAL_SERVER_ERROR, + "unavailable", + "the waitlist is unavailable right now", + ), + } +} + fn deploy_brief(row: &crate::mcp::tools::DeployRow) -> serde_json::Value { let mut v = json!({"sha": row.sha, "status": row.status}); let at = row.finished_at.or(Some(row.created_at)); @@ -694,7 +915,7 @@ fn layout(title: &str, body: Markup) -> Markup { } } -fn email_form(error: Option<&str>) -> Markup { +fn email_form(error: Option<&str>, invite_open: bool, invite_value: &str) -> Markup { layout( "Sign in", html! { @@ -710,6 +931,17 @@ fn email_form(error: Option<&str>) -> Markup { style="width:100%;padding:.5rem;margin:.25rem 0"; } br; + details open[invite_open] { + summary { "Have an invite code?" } + p { "Outer is in closed beta. New here without an allowlisted email? Paste the code someone on Outer shared with you." } + label { + "Invite code (optional)" br; + input type="text" name="invite_code" value=(invite_value) + autocomplete="off" placeholder="OUTER-XXXX-XXXX" + style="width:100%;padding:.5rem;margin:.25rem 0"; + } + } + br; button type="submit" style="padding:.5rem 1rem" { "Send code" } } }, @@ -816,7 +1048,7 @@ async fn sign_in_get(State(state): State, headers: HeaderMap) - ); } } - page(StatusCode::OK, email_form(None), None) + page(StatusCode::OK, email_form(None, false, ""), None) } /// `POST /console/sign-in`: one step per form (`email` → `code` → `terms`). @@ -833,7 +1065,11 @@ async fn sign_in_post( if !fetch_ok(&headers) { return page( StatusCode::FORBIDDEN, - email_form(Some("Open the sign-in page on outer.now and try again.")), + email_form( + Some("Open the sign-in page on outer.now and try again."), + false, + "", + ), None, ); } @@ -842,7 +1078,19 @@ async fn sign_in_post( match form.get("step").map(String::as_str) { Some("email") => { let email = form.get("email").cloned().unwrap_or_default(); - match crate::auth::send_code(&mcp.pool, &mcp.auth.config, &email, ip.as_deref()).await { + let invite_value = form + .get("invite_code") + .map(|raw| raw.trim().to_string()) + .unwrap_or_default(); + match crate::auth::send_code( + &mcp.pool, + &mcp.auth.config, + &email, + (!invite_value.is_empty()).then_some(invite_value.as_str()), + ip.as_deref(), + ) + .await + { Ok(_) => page( StatusCode::OK, code_form(&crate::auth::normalize_email(&email), None), @@ -856,7 +1104,11 @@ async fn sign_in_post( }; page( status, - email_form(Some(crate::auth::pages::friendly_error(err.code))), + email_form( + Some(crate::auth::pages::friendly_error(err.code)), + err.code == "closed_beta", + &invite_value, + ), None, ) } @@ -902,7 +1154,7 @@ async fn sign_in_post( let Some(session) = crate::auth::session_from_headers(&headers) else { return page( StatusCode::BAD_REQUEST, - email_form(Some("Sign in with email first.")), + email_form(Some("Sign in with email first."), false, ""), None, ); }; @@ -910,7 +1162,7 @@ async fn sign_in_post( else { return page( StatusCode::BAD_REQUEST, - email_form(Some("Sign in with email first.")), + email_form(Some("Sign in with email first."), false, ""), None, ); }; @@ -941,7 +1193,7 @@ async fn sign_in_post( } _ => page( StatusCode::BAD_REQUEST, - email_form(Some("Unknown sign-in step.")), + email_form(Some("Unknown sign-in step."), false, ""), None, ), } @@ -1111,7 +1363,7 @@ mod tests { let (app, pool, config, codes) = test_app().await; // A real account with a real API key: the Authorization header // must still not authenticate the console API. - crate::auth::send_code(&pool, &config, "ana@example.com", None) + crate::auth::send_code(&pool, &config, "ana@example.com", None, None) .await .unwrap(); let code = captured(&codes, "ana@example.com"); @@ -1223,7 +1475,7 @@ mod tests { let body: serde_json::Value = body.parse().unwrap(); assert_eq!( body, - serde_json::json!({"email": "ana@example.com", "plan": "free"}) + serde_json::json!({"email": "ana@example.com", "plan": "free", "is_admin": false}) ); } @@ -1848,4 +2100,239 @@ mod tests { "not_found" ); } + fn post_json(path: &str, cookie: &str, body: serde_json::Value) -> Request { + same_origin(Request::post(path).header("content-type", "application/json")) + .header("cookie", cookie) + .body(Body::from(body.to_string())) + .unwrap() + } + + /// A bare account (no apps): the invite endpoints need none. + async fn seed_account(pool: &SqlitePool, email: &str) { + sqlx::query( + "INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES (?, ?, 0, 0, 0)", + ) + .bind(format!("acct-{email}")) + .bind(email) + .execute(pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn console_me_reports_is_admin() { + let (app, pool, _, _) = test_app().await; + seed_account(&pool, "ana@example.com").await; + seed_account(&pool, "owner@example.com").await; + let ana = login(&pool, "ana@example.com").await; + let owner = login(&pool, "owner@example.com").await; + + let (status, _, body) = send(&app, authed("/api/console/me", &ana)).await; + assert_eq!(status, StatusCode::OK); + let me: serde_json::Value = body.parse().unwrap(); + assert_eq!(me["is_admin"], false); + let (status, _, body) = send(&app, authed("/api/console/me", &owner)).await; + assert_eq!(status, StatusCode::OK); + let me: serde_json::Value = body.parse().unwrap(); + assert_eq!(me["is_admin"], true, "OUTER_ADMIN_EMAILS decides"); + } + + #[tokio::test] + async fn console_admin_endpoints_forbid_non_admins() { + let (app, pool, _, _) = test_app().await; + seed_account(&pool, "ana@example.com").await; + let ana = login(&pool, "ana@example.com").await; + + let (status, _, body) = send(&app, authed("/api/console/admin/invites", &ana)).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + body.parse::().unwrap()["error"], + "forbidden" + ); + let (status, _, _) = send( + &app, + post_json( + "/api/console/admin/invites", + &ana, + serde_json::json!({"count": 1}), + ), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, _) = send( + &app, + post_json( + "/api/console/admin/invites/OUTER-AAAA-BBBB/revoke", + &ana, + serde_json::json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, _) = send(&app, authed("/api/console/admin/waitlist", &ana)).await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn console_invites_list_own_codes_with_status() { + let (app, pool, _, _) = test_app().await; + seed_account(&pool, "ana@example.com").await; + seed_account(&pool, "owner@example.com").await; + let first = + crate::auth::invites::mint_admin(&pool, "acct-ana@example.com", 1, Some("beta"), 7) + .await + .unwrap(); + let second = + crate::auth::invites::mint_admin(&pool, "acct-ana@example.com", 1, Some("beta"), 8) + .await + .unwrap(); + let made = [first[0].clone(), second[0].clone()]; + // One code joined by the owner, one revoked. + sqlx::query("UPDATE invite_code SET redeemedBy = 'acct-owner@example.com', redeemedAt = 9 WHERE code = ?") + .bind(&made[0]) + .execute(&pool) + .await + .unwrap(); + crate::auth::invites::revoke(&pool, &made[1], 10) + .await + .unwrap(); + let ana = login(&pool, "ana@example.com").await; + + let (status, headers, body) = send(&app, authed("/api/console/invites", &ana)).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers["cache-control"], "no-store"); + let list: serde_json::Value = body.parse().unwrap(); + let codes = list["codes"].as_array().unwrap(); + assert_eq!(codes.len(), 2); + assert_eq!(codes[0]["code"], made[1].as_str(), "newest first"); + assert_eq!(codes[0]["kind"], "admin"); + assert_eq!(codes[0]["note"], "beta"); + assert_eq!(codes[0]["revokedAt"], 10); + assert_eq!(codes[0]["redeemedByEmail"], serde_json::Value::Null); + assert_eq!(codes[1]["code"], made[0].as_str()); + assert_eq!(codes[1]["redeemedAt"], 9); + assert_eq!(codes[1]["redeemedByEmail"], "owner@example.com"); + assert_eq!(codes[1]["revokedAt"], serde_json::Value::Null); + } + + #[tokio::test] + async fn console_admin_invites_create_list_revoke() { + let (app, pool, _, _) = test_app().await; + seed_account(&pool, "owner@example.com").await; + let owner = login(&pool, "owner@example.com").await; + + for (count, want) in [(0, StatusCode::BAD_REQUEST), (51, StatusCode::BAD_REQUEST)] { + let (status, _, _) = send( + &app, + post_json( + "/api/console/admin/invites", + &owner, + serde_json::json!({"count": count}), + ), + ) + .await; + assert_eq!(status, want, "count {count} refuses"); + } + let (status, _, body) = send( + &app, + post_json( + "/api/console/admin/invites", + &owner, + serde_json::json!({"count": 3, "note": "beta friends"}), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + let created: serde_json::Value = body.parse().unwrap(); + assert_eq!(created["codes"].as_array().unwrap().len(), 3); + let first = created["codes"][0].as_str().unwrap().to_string(); + + // Paging: two per page, newest first, cursor continues. + let (status, _, body) = + send(&app, authed("/api/console/admin/invites?limit=2", &owner)).await; + assert_eq!(status, StatusCode::OK); + let page: serde_json::Value = body.parse().unwrap(); + assert_eq!(page["codes"].as_array().unwrap().len(), 2); + for code in page["codes"].as_array().unwrap() { + assert_eq!(code["createdByEmail"], "owner@example.com"); + assert_eq!(code["note"], "beta friends"); + } + let next = page["next"].as_str().unwrap().to_string(); + let (status, _, body) = send( + &app, + authed( + &format!("/api/console/admin/invites?limit=2&before={next}"), + &owner, + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + let rest: serde_json::Value = body.parse().unwrap(); + assert_eq!(rest["codes"].as_array().unwrap().len(), 1); + assert!(rest.get("next").is_none(), "last page carries no cursor"); + + // Revoke an unused code, then refuse the redeemed one. + let (status, _, body) = send( + &app, + post_json( + &format!("/api/console/admin/invites/{first}/revoke"), + &owner, + serde_json::json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body.parse::().unwrap()["revoked"], true); + sqlx::query("UPDATE invite_code SET redeemedBy = 'acct-owner@example.com', redeemedAt = 1 WHERE code = ?") + .bind(&first) + .execute(&pool) + .await + .unwrap(); + let (status, _, body) = send( + &app, + post_json( + &format!("/api/console/admin/invites/{first}/revoke"), + &owner, + serde_json::json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body.parse::().unwrap()["error"], + "already_used" + ); + let (status, _, _) = send( + &app, + post_json( + "/api/console/admin/invites/OUTER-ZZZZ-YYYY/revoke", + &owner, + serde_json::json!({}), + ), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn console_admin_waitlist_lists_entries() { + let (app, pool, _, _) = test_app().await; + seed_account(&pool, "owner@example.com").await; + sqlx::query( + "INSERT INTO waitlist (email, first_seen, attempts) VALUES \ + ('early@example.com', 5, 2), ('late@example.com', 9, 1)", + ) + .execute(&pool) + .await + .unwrap(); + let owner = login(&pool, "owner@example.com").await; + + let (status, _, body) = send(&app, authed("/api/console/admin/waitlist", &owner)).await; + assert_eq!(status, StatusCode::OK); + let list: serde_json::Value = body.parse().unwrap(); + let entries = list["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["email"], "late@example.com", "newest first"); + assert_eq!(entries[1]["attempts"], 2); + } } diff --git a/crates/outer/src/mcp/error.rs b/crates/outer/src/mcp/error.rs index 6cf9224..bff121b 100644 --- a/crates/outer/src/mcp/error.rs +++ b/crates/outer/src/mcp/error.rs @@ -92,7 +92,7 @@ impl ToolError { "rate_limited" => Self::rate_limited(message, err.retry_after_s.unwrap_or(60)), "forbidden" | "closed_beta" => Self::forbidden(message), "invalid_email" | "invalid_code" | "too_many_attempts" | "code_expired" - | "invalid_token" | "terms_required" => Self::invalid_argument(message), + | "invalid_token" | "terms_required" | "invite_used" => Self::invalid_argument(message), _ => { tracing::warn!(code = err.code, message = %err.message, "auth module error"); Self::internal("sign-in failed on our side; try again in a minute") diff --git a/crates/outer/src/mcp/registry.rs b/crates/outer/src/mcp/registry.rs index 7fc27fc..a97e751 100644 --- a/crates/outer/src/mcp/registry.rs +++ b/crates/outer/src/mcp/registry.rs @@ -159,7 +159,7 @@ pub fn registry() -> Vec { ), tool::( "sign_in", - "Start email sign-in (clients without OAuth): emails a 6-digit code. Then call verify_code. Disappears after sign-in.", + "Start email sign-in (clients without OAuth): emails a 6-digit code. Then call verify_code. During the closed beta a new email also needs invite_code (OUTER-XXXX-XXXX). Disappears after sign-in.", Public, false, ), tool::( diff --git a/crates/outer/src/mcp/tools/account.rs b/crates/outer/src/mcp/tools/account.rs index 7a8c95a..87fb89f 100644 --- a/crates/outer/src/mcp/tools/account.rs +++ b/crates/outer/src/mcp/tools/account.rs @@ -22,6 +22,10 @@ pub struct DocsInput { pub struct SignInInput { /// The email address to sign in as. pub email: String, + /// Optional invite code for the closed beta (`OUTER-XXXX-XXXX`; dashes + /// and the prefix are optional). Only needed when the email is new and + /// not allowlisted: existing, allowlisted and admin emails ignore it. + pub invite_code: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -91,6 +95,7 @@ pub async fn sign_in(input: SignInInput, ctx: &Ctx<'_>) -> Result(path: string): Promise { return (await res.json()) as T; } +export async function apiPost(path: string, body: unknown): Promise { + const res = await fetch(path, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.status === 401) { + globalThis.location.assign("/console/sign-in"); + throw new Error("signed out"); + } + if (!res.ok) { + throw new Error(`request failed: ${res.status}`); + } + return (await res.json()) as T; +} + export interface LastDeploy { sha: string; status: string; @@ -114,6 +130,36 @@ export interface SecretsSummary { secrets: SecretEntry[]; } +export interface InviteEntry { + code: string; + kind: string; + note: string | null; + createdAt: number; + redeemedAt: number | null; + redeemedByEmail: string | null; + revokedAt: number | null; + createdByEmail?: string | null; +} + +export interface InvitesSummary { + codes: InviteEntry[]; +} + +export interface AdminInvitesPage { + codes: InviteEntry[]; + next?: string; +} + +export interface WaitlistEntry { + email: string; + first_seen: number; + attempts: number; +} + +export interface WaitlistSummary { + entries: WaitlistEntry[]; +} + export function shortSha(sha: string): string { return sha.slice(0, 7); } diff --git a/web/pages/console/_islands/common.tsx b/web/pages/console/_islands/common.tsx index af0ed16..3ad1656 100644 --- a/web/pages/console/_islands/common.tsx +++ b/web/pages/console/_islands/common.tsx @@ -14,7 +14,14 @@ export const VIEWS = [ { id: "settings", label: "Settings" }, ] as const; -export type ViewId = (typeof VIEWS)[number]["id"]; +export const ACCOUNT_VIEWS = [ + { id: "invites", label: "Invite codes" }, + { id: "admin", label: "Admin" }, +] as const; + +export type AccountViewId = (typeof ACCOUNT_VIEWS)[number]["id"]; + +export type ViewId = (typeof VIEWS)[number]["id"] | AccountViewId; export const RANGES = [ { hours: 24, label: "24h", title: "Last 24 hours" }, @@ -29,7 +36,10 @@ export interface Route { } export function toViewId(raw: string | null): ViewId { - return VIEWS.some((v) => v.id === raw) ? (raw as ViewId) : "overview"; + if (VIEWS.some((v) => v.id === raw) || ACCOUNT_VIEWS.some((v) => v.id === raw)) { + return raw as ViewId; + } + return "overview"; } export function toRange(raw: string | null): number { diff --git a/web/pages/console/_islands/console.tsx b/web/pages/console/_islands/console.tsx index ba52dbb..c49d6ba 100644 --- a/web/pages/console/_islands/console.tsx +++ b/web/pages/console/_islands/console.tsx @@ -10,13 +10,17 @@ import { watch } from "ilha"; import { api, + apiPost, type ActionsPage, + type AdminInvitesPage, type AppDetail, type ConsoleApp, type ErrorsSummary, + type InvitesSummary, type LogLine, type MetricsSummary, type SecretsSummary, + type WaitlistSummary, } from "./api.ts"; import { ActionsIcon, @@ -34,12 +38,16 @@ import { PlusIcon, SecretsIcon, SettingsIcon, + ShieldIcon, SignOutIcon, + TicketIcon, } from "./icons.tsx"; import { ActionsView, + AdminView, DeploysView, ErrorsView, + InvitesView, LogsView, MetricsView, OverviewView, @@ -48,6 +56,7 @@ import { } from "./views.tsx"; import { + ACCOUNT_VIEWS, CopyButton, RANGES, VIEWS, @@ -143,12 +152,18 @@ const VIEW_TITLES: Record = { errors: "Errors", secrets: "Secrets", settings: "Settings", + invites: "Invite codes", + admin: "Admin", }; export function Console() { const route = atom(readRoute()); const apps = atom>({ kind: "loading" }); const email = atom(""); + const isAdmin = atom(false); + const invites = atom>({ kind: "loading" }); + const adminInvites = atom>({ kind: "loading" }); + const waitlist = atom>({ kind: "loading" }); const detail = atom>({ kind: "loading" }); const metrics = atom>({ kind: "loading", @@ -173,7 +188,9 @@ export function Console() { document.title = app && r.app ? `${r.app} · ${VIEW_TITLES[r.view]} · Outer console` - : "Console · Outer"; + : isAccountView(r.view) + ? `${VIEW_TITLES[r.view]} · Outer console` + : "Console · Outer"; } function loadersFor(view: ViewId): Array<"metrics" | "actions" | "errors" | "logs" | "secrets"> { @@ -193,6 +210,8 @@ export function Console() { case "secrets": return ["secrets"]; case "settings": + case "invites": + case "admin": return []; } } @@ -312,8 +331,98 @@ export function Console() { await Promise.all(jobs); } + function isAccountView(view: ViewId): boolean { + return view === "invites" || view === "admin"; + } + + async function loadAccountView(view: ViewId, myEpoch: number): Promise { + const fresh = () => epoch === myEpoch; + if (view === "invites") { + invites.set({ kind: "loading" }); + try { + const summary = await api("/api/console/invites"); + if (fresh()) { + invites.set({ kind: "ready", value: summary }); + setTitle(route(), null); + } + } catch (err: unknown) { + if (fresh()) { + invites.set({ kind: "error", message: String(err) }); + } + } + return; + } + if (view === "admin") { + adminInvites.set({ kind: "loading" }); + waitlist.set({ kind: "loading" }); + try { + const [page, list] = await Promise.all([ + api("/api/console/admin/invites?limit=50"), + api("/api/console/admin/waitlist?limit=100"), + ]); + if (fresh()) { + adminInvites.set({ kind: "ready", value: page }); + waitlist.set({ kind: "ready", value: list }); + setTitle(route(), null); + } + } catch (err: unknown) { + if (fresh()) { + const message = String(err); + adminInvites.set({ kind: "error", message }); + waitlist.set({ kind: "error", message }); + } + } + } + } + + async function generateCodes(count: number, note: string): Promise { + await apiPost<{ codes: string[] }>("/api/console/admin/invites", { + count, + note: note || undefined, + }); + await loadAccountView("admin", epoch); + } + + async function revokeCode(code: string): Promise { + await apiPost<{ revoked: boolean }>( + `/api/console/admin/invites/${encodeURIComponent(code)}/revoke`, + {}, + ); + await loadAccountView("admin", epoch); + } + + async function loadMoreAdminCodes(): Promise { + const current = adminInvites(); + if (current.kind !== "ready" || !current.value.next) { + return; + } + const before = current.value.next; + try { + const page = await api( + `/api/console/admin/invites?limit=50&before=${encodeURIComponent(before)}`, + ); + const latest = adminInvites(); + if (latest.kind !== "ready") { + return; + } + adminInvites.set({ + kind: "ready", + value: { codes: [...latest.value.codes, ...page.codes], next: page.next }, + }); + } catch { + // The table keeps what it has; retry via the view's Retry button. + } + } + async function enter(r: Route, myEpoch: number): Promise { - if (!r.app) { + if (isAccountView(r.view)) { + setTitle(r, null); + await loadAccountView(r.view, myEpoch); + if (!r.app) { + return; + } + // Fall through: keep the app detail (and the sidebar) live too. + } else if (!r.app) { return; } const fresh = () => epoch === myEpoch; @@ -359,6 +468,11 @@ export function Console() { void enter(r, epoch); return; } + if (isAccountView(r.view)) { + setTitle(r, null); + void loadAccountView(r.view, epoch); + return; + } if (r.app) { void loadViewData(r.app, r.view, r.range, epoch, false); } @@ -419,12 +533,21 @@ export function Console() { .then(async (data) => { apps.set({ kind: "ready", value: data.apps }); try { - const me = await api<{ email: string }>("/api/console/me"); + const me = await api<{ email: string; is_admin?: boolean }>("/api/console/me"); email.set(me.email); + isAdmin.set(me.is_admin ?? false); } catch { email.set(""); + isAdmin.set(false); } const r = route(); + if (isAccountView(r.view)) { + // Account views never depend on the app: a missing or foreign + // `?app=` still shows them instead of hanging on skeletons. + epoch += 1; + await enter(r, epoch); + return; + } if (!r.app) { if (data.apps.length > 0) { const first = data.apps[0].slug; @@ -461,6 +584,12 @@ export function Console() { window.addEventListener("popstate", () => { const r = readRoute(); route.set(r); + if (isAccountView(r.view)) { + // Like boot: the account view stands alone, whatever `?app=` says. + epoch += 1; + void enter(r, epoch); + return; + } if (!r.app) { return; } @@ -473,9 +602,10 @@ export function Console() { void enter(r, epoch); }); // Noite's live feed, polling edition: refresh the visible view only. + // Account views never poll: invites change through this shell alone. window.setInterval(() => { const r = route(); - if (!r.app || document.hidden) { + if (!r.app || isAccountView(r.view) || document.hidden) { return; } void loadViewData(r.app, r.view, r.range, epoch, true); @@ -505,7 +635,8 @@ export function Console() { ); } const list = appsPanel.value; - if (list.length === 0) { + const accountView = isAccountView(r.view); + if (list.length === 0 && !accountView) { return (
@@ -518,6 +649,31 @@ export function Console() {
); } + const accountPanel = accountView ? ( + r.view === "invites" ? ( + { + epoch += 1; + void loadAccountView("invites", epoch); + }} + /> + ) : ( + generateCodes(count, note)} + onRevoke={(code) => revokeCode(code)} + onMore={() => void loadMoreAdminCodes()} + onRetry={() => { + epoch += 1; + void loadAccountView("admin", epoch); + }} + /> + ) + ) : null; const active = list.find((a) => a.slug === r.app) ?? (detailPanel.kind === "ready" ? detailPanel.value : null); @@ -542,7 +698,9 @@ export function Console() { aria-label="Outer console" onclick={(e: Event) => { e.preventDefault(); - navigate({ app: list[0].slug, view: "overview" }); + if (list.length > 0) { + navigate({ app: list[0].slug, view: "overview" }); + } }} > @@ -650,6 +808,32 @@ export function Console() { {email() || "Account"} +
  • + +
  • + {isAdmin() && ( +
  • + +
  • + )}
  • ))} + + {ACCOUNT_VIEWS.filter((v) => v.id !== "admin" || isAdmin()).map((v) => ( +
  • + +
  • + ))}
    )} + {!active && accountView && ( +
    +
    +
    +
    +

    {VIEW_TITLES[r.view]}

    +
    +
    {accountPanel}
    +
    +
    +
    + )} ); } @@ -892,6 +1103,10 @@ function ViewIcon({ id }: { id: ViewId }) { return ; case "settings": return ; + case "invites": + return ; + case "admin": + return ; } } diff --git a/web/pages/console/_islands/icons.tsx b/web/pages/console/_islands/icons.tsx index fb40c99..e75689e 100644 --- a/web/pages/console/_islands/icons.tsx +++ b/web/pages/console/_islands/icons.tsx @@ -26,7 +26,9 @@ import { Plus, Rocket, Settings, + ShieldCheck, Terminal, + Ticket, TriangleAlert, X, type IconNode, @@ -148,3 +150,11 @@ export function ChevronDownIcon({ size = 14 }: { size?: number }) { export function DatabaseIcon({ size = 18 }: { size?: number }) { return ; } + +export function TicketIcon({ size = 18 }: { size?: number }) { + return ; +} + +export function ShieldIcon({ size = 18 }: { size?: number }) { + return ; +} diff --git a/web/pages/console/_islands/views.tsx b/web/pages/console/_islands/views.tsx index a543946..9aa758d 100644 --- a/web/pages/console/_islands/views.tsx +++ b/web/pages/console/_islands/views.tsx @@ -12,16 +12,21 @@ // with an ellipsis (`truncate`) and exposes the full text via `title`. // Tables are `table-fixed` with column widths. `break-words` is reserved // for text that should wrap: log lines and the expanded deploy message. +import { atom } from "ilha"; import { shortSha, when, + type AdminInvitesPage, type AppDetail, type ConsoleDeploy, type ErrorsSummary, + type InviteEntry, + type InvitesSummary, type LogLine, type MetricsBucket, type MetricsSummary, type SecretsSummary, + type WaitlistSummary, } from "./api.ts"; import { CopyButton, @@ -1177,3 +1182,448 @@ export function SettingsView({ app }: { app: AppDetail }) { ); } + +// --- Invites (account) --------------------------------------------------------- + +function inviteStatus(entry: InviteEntry): { label: string; badge: string } { + if (entry.revokedAt != null) { + return { label: "Revoked", badge: "badge-ghost" }; + } + if (entry.redeemedAt != null) { + return { label: "Joined", badge: "badge-success" }; + } + return { label: "Available", badge: "badge-info" }; +} + +export function InvitesView({ + panel, + isAdmin, + onRetry, +}: { + panel: Panel; + isAdmin: boolean; + onRetry: () => void; +}) { + if (panel.kind === "loading") { + return ; + } + if (panel.kind === "error") { + return ( +
    + +
    + +
    +
    + ); + } + const codes = panel.value.codes; + const available = codes.filter( + (c) => c.revokedAt == null && c.redeemedAt == null, + ).length; + return ( +
    +
    +
    +

    + Outer is in closed beta: one code admits one new account. Share a + code with a friend and it is spent when they sign in + {available === 1 ? " — you have 1 unused code left." : ` — you have ${available} unused codes left.`} +

    +
    +
    + {codes.length === 0 ? ( + + {isAdmin + ? "Admins get no automatic codes — generate some on the Admin page." + : "You have no invite codes."} + + ) : ( +
    +
    +
    + + + + + + + + + + + + + + + + + + + {codes.map((entry) => { + const status = inviteStatus(entry); + return ( + + + + + + + + ); + })} + +
    CodeKindStatusJoined byCreated
    + + + {entry.code} + + + + + {entry.kind} + + + {status.label} + + + {entry.redeemedByEmail ?? entry.note ?? "—"} + + {timeAgo(entry.createdAt)} +
    +
    +
    +
    + )} +
    + ); +} + +// --- Admin (account) ----------------------------------------------------------- + +export function AdminView({ + codes, + waitlist, + isAdmin, + onGenerate, + onRevoke, + onMoreCodes, + onRetry, +}: { + codes: Panel; + waitlist: Panel; + isAdmin: boolean; + onGenerate: (count: number, note: string) => Promise; + onRevoke: (code: string) => Promise; + onMoreCodes: () => void; + onRetry: () => void; +}) { + if (!isAdmin) { + return ( +
    + +
    + ); + } + return ( +
    + + + +
    + ); +} + +function GenerateCard({ + onGenerate, +}: { + onGenerate: (count: number, note: string) => Promise; +}) { + const busy = atom(false); + const error = atom(null); + async function submit(): Promise { + const countInput = document.getElementById("invite-count") as HTMLInputElement | null; + const noteInput = document.getElementById("invite-note") as HTMLInputElement | null; + const count = Number(countInput?.value ?? ""); + if (!Number.isInteger(count) || count < 1 || count > 50) { + error.set("Count must be between 1 and 50."); + return; + } + busy.set(true); + error.set(null); + try { + await onGenerate(count, (noteInput?.value ?? "").trim()); + } catch { + error.set("Couldn't generate codes. Try again."); + } finally { + busy.set(false); + } + } + return ( +
    +
    +

    Generate codes

    +
    + + + +
    + {error() && ( + + )} +
    +
    + ); +} + +function AdminCodesCard({ + panel, + onRevoke, + onMore, + onRetry, +}: { + panel: Panel; + onRevoke: (code: string) => Promise; + onMore: () => void; + onRetry: () => void; +}) { + const revoking = atom(null); + const error = atom(null); + if (panel.kind === "loading") { + return ; + } + if (panel.kind === "error") { + return ( +
    + +
    + +
    +
    + ); + } + const page = panel.value; + async function revoke(code: string): Promise { + revoking.set(code); + error.set(null); + try { + await onRevoke(code); + } catch { + error.set("Couldn't revoke that code. Try again."); + } finally { + revoking.set(null); + } + } + return ( +
    +
    +

    All codes

    + {error() && ( + + )} + {page.codes.length === 0 ? ( + No codes yet — generate some above. + ) : ( +
    + + + + + + + + + + + + + + + + + + + + + {page.codes.map((entry) => { + const status = inviteStatus(entry); + const usable = entry.revokedAt == null && entry.redeemedAt == null; + return ( + + + + + + + + + ); + })} + +
    CodeKindCreatorNote / joined byCreated + Actions +
    + + + {entry.code} + + + + + {entry.kind} + + {entry.createdByEmail ?? "—"} + + {entry.redeemedByEmail ?? entry.note ?? status.label} + + {timeAgo(entry.createdAt)} + + {usable ? ( + + ) : ( + + {status.label} + + )} +
    +
    + )} + {page.next && ( +
    + +
    + )} +
    +
    + ); +} + +function WaitlistCard({ + panel, + onRetry, +}: { + panel: Panel; + onRetry: () => void; +}) { + if (panel.kind === "loading") { + return ; + } + if (panel.kind === "error") { + return ( +
    + +
    + +
    +
    + ); + } + const entries = panel.value.entries; + return ( +
    +
    +

    Waitlist

    + {entries.length === 0 ? ( + Nobody waiting. + ) : ( +
    + + + + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + ))} + +
    EmailTriesFirst seen
    + {entry.email} + {entry.attempts} + {timeAgo(entry.first_seen)} +
    +
    + )} +
    +
    + ); +} From e18776752d95fc56d86882731aa98c968dec8a82 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Sat, 24 Oct 2026 18:50:20 +0200 Subject: [PATCH 2/2] Console invites: secondary columns hide and codes fit on phones --- web/pages/console/_islands/views.tsx | 56 ++++++++++++++-------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/web/pages/console/_islands/views.tsx b/web/pages/console/_islands/views.tsx index 9aa758d..3ebcfbe 100644 --- a/web/pages/console/_islands/views.tsx +++ b/web/pages/console/_islands/views.tsx @@ -1244,21 +1244,21 @@ export function InvitesView({
    - +
    - - - - + + + + - + - - + + @@ -1268,13 +1268,13 @@ export function InvitesView({ - - @@ -1477,22 +1477,22 @@ function AdminCodesCard({ No codes yet — generate some above. ) : (
    -
    CodeKindKind StatusJoined byCreatedJoined byCreated
    - + {entry.code} + {entry.kind} @@ -1283,12 +1283,12 @@ export function InvitesView({ {entry.redeemedByEmail ?? entry.note ?? "—"} + {timeAgo(entry.createdAt)}
    +
    - - - - - - + + + + + + - - - - + + + + @@ -1506,25 +1506,25 @@ function AdminCodesCard({ - - -
    CodeKindCreatorNote / joined byCreatedKindCreatorNote / joined byCreated Actions
    - + {entry.code} + {entry.kind} + {entry.createdByEmail ?? "—"} {entry.redeemedByEmail ?? entry.note ?? status.label} + {timeAgo(entry.createdAt)}