Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/outer/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,10 @@ pub async fn verify_code_core(
});
}
}
if !is_admin(cfg, &email) {
// Only people who joined with an invite get codes to pass on
// (owner, 2026-10-24): not admins, not allowlisted emails, and
// not app collaborators, who are admitted through the allowlist.
if carried_invite.is_some() && !is_admin(cfg, &email) {
invites::mint_grants(&mut tx, &id, now)
.await
.map_err(|err| AuthError::new("db", format!("{err}")))?;
Expand Down
8 changes: 5 additions & 3 deletions crates/outer/src/auth/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1424,9 +1424,11 @@ async fn closed_beta_waitlists_by_default() {
.unwrap();
}

/// A fresh account mints exactly 3 grant codes; an admin account mints none.
/// Codes go only to people who joined with an invite: an account admitted
/// any other way (open sign-up, the allowlist — how app collaborators get
/// in) and an admin mint none. The invite round trip below pins the 3.
#[tokio::test]
async fn fresh_account_gets_three_grants_admin_gets_none() {
async fn only_invite_joiners_get_grants() {
let pool = test_pool().await;
let (config, codes) = test_config();

Expand All @@ -1448,7 +1450,7 @@ async fn fresh_account_gets_three_grants_admin_gets_none() {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(ana_grants, 3);
assert_eq!(ana_grants, 0, "no invite, no codes to pass on");
let (owner_grants,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM invite_code WHERE createdBy = \
(SELECT id FROM user WHERE email = 'owner@example.com')",
Expand Down
17 changes: 12 additions & 5 deletions crates/outer/src/deploy/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3063,14 +3063,18 @@ mod tests {

async fn test_pool() -> (tempfile::TempDir, SqlitePool) {
let dir = tempfile::tempdir().unwrap();
let pool = crate::db::open(&dir.path().join("outer.sqlite"))
.await
.unwrap();
let pool = test_pool_in(dir.path()).await;
(dir, pool)
}
/// A pool whose database lives in `dir`; the caller keeps `dir` alive
/// for as long as the pool may open new connections.
async fn test_pool_in(dir: &Path) -> SqlitePool {
let pool = crate::db::open(&dir.join("outer.sqlite")).await.unwrap();
sqlx::query("INSERT INTO user (id, email, lastActiveAt, createdAt, updatedAt) VALUES ('owner1', 'o@example.com', 0, 0, 0)")
.execute(&pool)
.await
.unwrap();
(dir, pool)
pool
}
fn jev_client(base_url: &str) -> JevClient {
let mut config = JevConfig::new(
Expand Down Expand Up @@ -3969,7 +3973,10 @@ exit 0
) {
let data = tempfile::tempdir().unwrap();
let deployer = Deployer::new(test_settings(data.path().to_path_buf()), 1);
let (_ddir, pool) = test_pool().await;
// The database lives in `data`, which the caller keeps: a TempDir
// dropped here would delete it while the pool can still open new
// connections ("unable to open database file", flaky in CI).
let pool = test_pool_in(data.path()).await;
let app = test_app(10301);
insert_test_app(&pool, &app).await;
let key = crate::secrets::SecretsKey::test_key();
Expand Down
5 changes: 3 additions & 2 deletions web/docs/sign-in.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ After sign-in, `sign_in` and `verify_code` disappear from the tool list.

## Invite codes

Every new account gets 3 invite codes to share (`OUTER-XXXX-XXXX`, shown on
the console's Invites page). A code admits one new account and is spent when
Everyone who joins with an invite code gets 3 codes of their own to share
(`OUTER-XXXX-XXXX`, shown on the console's Invites page). App collaborators
and allowlisted emails get in without a code and get none. A code admits one new account and is spent when
that account signs in — allowlisted and existing emails never burn one. Admins
generate codes in bulk from the console's Admin page instead of getting
automatic ones.
Expand Down
2 changes: 1 addition & 1 deletion web/pages/console/_islands/console.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ export function Console() {
<a class="rail-tile tooltip tooltip-right" data-tip="Docs" href="/" aria-label="Docs">
<DocsIcon />
</a>
<div class={`dropdown dropdown-right dropdown-top${ui().accountOpen ? " dropdown-open" : ""}`}>
<div class={`dropdown dropdown-right dropdown-end${ui().accountOpen ? " dropdown-open" : ""}`}>
<button
type="button"
class="tooltip tooltip-right rounded-[14px]"
Expand Down
12 changes: 12 additions & 0 deletions web/pages/console/console.css
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ body {
border-radius: var(--radius-box);
padding: 10px 0;
min-height: calc(100dvh - 24px);
/* `sticky` makes the rail its own stacking context, so its dropdowns and
tooltips (daisyUI z-index 999/2) only stack inside it. Without a z-index
the later, positioned cards (`.card` is `position: relative`) paint over
them and the menus open invisibly behind the content. */
z-index: 20;
}

.rail-apps {
Expand Down Expand Up @@ -440,3 +445,10 @@ a.rail-tile:hover {
.side .menu :where(li > a.menu-active) {
@apply bg-base-200 text-base-content font-medium;
}

/* The mobile drawer still covers the rail (rail z-index 20 > daisyUI's 10). */
@media (max-width: 767px) {
.drawer-side {
z-index: 30;
}
}
Loading