diff --git a/migrations/020_multi_use_invites.sql b/migrations/020_multi_use_invites.sql new file mode 100644 index 00000000..ff1c42a3 --- /dev/null +++ b/migrations/020_multi_use_invites.sql @@ -0,0 +1,7 @@ +-- Add multi-use invite code support. +-- max_uses NULL = unlimited, 1 = single-use (backward compat default). +ALTER TABLE invite_codes ADD COLUMN max_uses INTEGER DEFAULT 1; +ALTER TABLE invite_codes ADD COLUMN use_count INTEGER NOT NULL DEFAULT 0; + +-- Backfill: existing used codes should reflect their single use. +UPDATE invite_codes SET use_count = 1 WHERE used_by IS NOT NULL; diff --git a/src/assets/style.css b/src/assets/style.css index 66798ffc..997111c6 100644 --- a/src/assets/style.css +++ b/src/assets/style.css @@ -531,6 +531,9 @@ pre code { .request-row { cursor: pointer; border-bottom: 1px solid var(--border); } .request-row:hover { background: var(--bg-hover, rgba(255,255,255,0.03)); } .request-row td { padding: 10px 16px; vertical-align: middle; } +.request-row td:first-child { position: relative; padding-left: 28px; } +.request-row td:first-child::before { content: "\25B8"; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--text-muted); font-size: 0.8rem; transition: transform 0.15s; } +.request-row.expanded td:first-child::before { transform: translateY(-50%) rotate(90deg); } .request-row.expanded { background: var(--bg-hover, rgba(255,255,255,0.03)); } .request-detail { border-bottom: 1px solid var(--border); border-left: 3px solid var(--accent); } .request-expanded .request-detail { display: table-row !important; } diff --git a/src/cli/invite.rs b/src/cli/invite.rs index ba8e0a7e..9c95964a 100644 --- a/src/cli/invite.rs +++ b/src/cli/invite.rs @@ -12,7 +12,7 @@ pub fn run(expires: Option<&str>, ctx: &CliContext) -> Result<()> { }; ctx.db - .create_invite(&code, None, expires_at.as_deref()) + .create_invite(&code, None, expires_at.as_deref(), Some(1)) .map_err(|e| anyhow::anyhow!("failed to create invite: {e}"))?; let display_host = if ctx.config.web_bind == "0.0.0.0" { diff --git a/src/db/schema.rs b/src/db/schema.rs index a9b076c9..d5e100d7 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -22,6 +22,8 @@ const MIGRATIONS: &[&str] = &[ include_str!("../../migrations/016_convoy_permission.sql"), include_str!("../../migrations/017_convoy_sync_tracking.sql"), include_str!("../../migrations/018_dependency_tree.sql"), + include_str!("../../migrations/019_queue_unique_constraints.sql"), + include_str!("../../migrations/020_multi_use_invites.sql"), ]; pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> { diff --git a/src/db/tests.rs b/src/db/tests.rs index e1afcb11..efccdc83 100644 --- a/src/db/tests.rs +++ b/src/db/tests.rs @@ -474,7 +474,7 @@ fn create_and_use_invite() { .unwrap(); let invite_id = db - .create_invite("INVITE-123", Some(admin_id), None) + .create_invite("INVITE-123", Some(admin_id), None, Some(1)) .unwrap(); assert!(invite_id > 0); @@ -508,7 +508,7 @@ fn create_and_use_invite() { #[test] fn create_invite_without_creator() { let db = test_db(); - let invite_id = db.create_invite("ORPHAN-1", None, None).unwrap(); + let invite_id = db.create_invite("ORPHAN-1", None, None, Some(1)).unwrap(); let invite = db .get_invite("ORPHAN-1") .unwrap() @@ -524,8 +524,13 @@ fn expired_invite_rejected() { .insert_user("admin", Some("adm-profile"), Some("pw"), "admin", false) .unwrap(); - db.create_invite("EXPIRED-1", Some(admin_id), Some("2020-01-01 00:00:00")) - .unwrap(); + db.create_invite( + "EXPIRED-1", + Some(admin_id), + Some("2020-01-01 00:00:00"), + Some(1), + ) + .unwrap(); let user_id = db .insert_user( @@ -787,8 +792,9 @@ fn list_invite_codes_with_usernames() { let admin_id = db .insert_user("admin", Some("p1"), Some("pw"), "admin", false) .unwrap(); - db.create_invite("CODE-1", Some(admin_id), None).unwrap(); - db.create_invite("CODE-2", None, None).unwrap(); + db.create_invite("CODE-1", Some(admin_id), None, Some(1)) + .unwrap(); + db.create_invite("CODE-2", None, None, Some(1)).unwrap(); let codes = db.list_invite_codes().unwrap(); assert_eq!(codes.len(), 2); @@ -1377,7 +1383,7 @@ fn delete_user_sets_invite_created_by_null() { .insert_user("player", Some("p2"), Some("pw"), "player", false) .unwrap(); - db.create_invite("CODE-BY-PLAYER", Some(player), None) + db.create_invite("CODE-BY-PLAYER", Some(player), None, Some(1)) .unwrap(); let result = db.delete_user(player).unwrap(); @@ -1401,7 +1407,9 @@ fn delete_invite_unused() { let admin = db .insert_user("admin", Some("p1"), Some("pw"), "admin", false) .unwrap(); - let invite_id = db.create_invite("CODE-DEL", Some(admin), None).unwrap(); + let invite_id = db + .create_invite("CODE-DEL", Some(admin), None, Some(1)) + .unwrap(); let result = db.delete_invite(invite_id).unwrap(); assert!(matches!(result, DeleteInviteResult::Deleted)); @@ -1416,7 +1424,9 @@ fn delete_invite_already_used() { let admin = db .insert_user("admin", Some("p1"), Some("pw"), "admin", false) .unwrap(); - let invite_id = db.create_invite("CODE-USED", Some(admin), None).unwrap(); + let invite_id = db + .create_invite("CODE-USED", Some(admin), None, Some(1)) + .unwrap(); let player = db .insert_user("player", Some("p2"), Some("pw"), "player", false) .unwrap(); diff --git a/src/db/users.rs b/src/db/users.rs index 78ea17da..a49a0281 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -41,6 +41,8 @@ pub struct InviteCode { pub created_at: String, pub used_at: Option, pub expires_at: Option, + pub max_uses: Option, + pub use_count: i64, } #[derive(Debug, Clone)] @@ -325,10 +327,11 @@ impl Database { code: &str, created_by: Option, expires_at: Option<&str>, + max_uses: Option, ) -> rusqlite::Result { self.conn.execute( - "INSERT INTO invite_codes (code, created_by, expires_at) VALUES (?1, ?2, ?3)", - params![code, created_by, expires_at], + "INSERT INTO invite_codes (code, created_by, expires_at, max_uses) VALUES (?1, ?2, ?3, ?4)", + params![code, created_by, expires_at, max_uses], )?; Ok(self.conn.last_insert_rowid()) } @@ -336,7 +339,7 @@ impl Database { pub fn get_invite(&self, code: &str) -> rusqlite::Result> { self.conn .query_row( - "SELECT id, code, created_by, used_by, created_at, used_at, expires_at + "SELECT id, code, created_by, used_by, created_at, used_at, expires_at, max_uses, use_count FROM invite_codes WHERE code = ?1", params![code], row_to_invite_code, @@ -345,11 +348,12 @@ impl Database { } /// Attempt to use an invite code. Returns the number of rows affected (1 if - /// successful, 0 if the code was already used or expired). + /// successful, 0 if the code is exhausted or expired). pub fn use_invite(&self, code: &str, user_id: i64) -> rusqlite::Result { self.conn.execute( - "UPDATE invite_codes SET used_by = ?1, used_at = datetime('now') - WHERE code = ?2 AND used_by IS NULL + "UPDATE invite_codes SET used_by = ?1, used_at = datetime('now'), use_count = use_count + 1 + WHERE code = ?2 + AND (max_uses IS NULL OR use_count < max_uses) AND (expires_at IS NULL OR expires_at > datetime('now'))", params![user_id, code], ) @@ -358,6 +362,7 @@ impl Database { pub fn list_invite_codes(&self) -> rusqlite::Result> { let mut stmt = self.conn.prepare( "SELECT ic.id, ic.code, ic.created_by, ic.used_by, ic.created_at, ic.used_at, ic.expires_at, + ic.max_uses, ic.use_count, u1.username AS created_by_username, u2.username AS used_by_username FROM invite_codes ic @@ -375,17 +380,23 @@ impl Database { created_at: row.get(4)?, used_at: row.get(5)?, expires_at: row.get(6)?, + max_uses: row.get(7)?, + use_count: row.get(8)?, }, - created_by_username: row.get(7)?, - used_by_username: row.get(8)?, + created_by_username: row.get(9)?, + used_by_username: row.get(10)?, }) })?; rows.collect() } pub fn delete_invite(&self, invite_id: i64) -> rusqlite::Result { + // Allow deletion of unused single-use codes (use_count = 0 and max_uses = 1) + // and any multi-use/unlimited codes regardless of use_count. + // Block deletion only for fully-consumed single-use codes. let affected = self.conn.execute( - "DELETE FROM invite_codes WHERE id = ?1 AND used_by IS NULL", + "DELETE FROM invite_codes WHERE id = ?1 + AND NOT (max_uses = 1 AND use_count >= 1)", params![invite_id], )?; if affected > 0 { @@ -599,6 +610,8 @@ fn row_to_invite_code(row: &rusqlite::Row<'_>) -> rusqlite::Result { created_at: row.get(4)?, used_at: row.get(5)?, expires_at: row.get(6)?, + max_uses: row.get(7)?, + use_count: row.get(8)?, }) } diff --git a/src/web/handlers/admin.rs b/src/web/handlers/admin.rs index 85aba57d..e4e9201c 100644 --- a/src/web/handlers/admin.rs +++ b/src/web/handlers/admin.rs @@ -140,12 +140,18 @@ pub struct InviteView { pub used_by_username: Option, pub created_at: String, pub expires_at: Option, + pub max_uses: Option, + pub use_count: i64, pub status: String, // "available", "used", or "expired" } impl InviteView { fn from_db(ic: InviteCodeWithUsers) -> Self { - let status = if ic.invite.used_by.is_some() { + let exhausted = ic + .invite + .max_uses + .is_some_and(|max| ic.invite.use_count >= max); + let status = if exhausted { "used" } else if crate::web::invite::is_invite_expired(ic.invite.expires_at.as_deref()) { "expired" @@ -159,6 +165,8 @@ impl InviteView { used_by_username: ic.used_by_username, created_at: ic.invite.created_at, expires_at: ic.invite.expires_at, + max_uses: ic.invite.max_uses, + use_count: ic.invite.use_count, status: status.to_string(), } } @@ -199,6 +207,7 @@ pub struct CsrfOnly { #[derive(serde::Deserialize)] pub struct InviteForm { expiry: String, + max_uses: String, csrf_token: String, } @@ -662,12 +671,20 @@ pub async fn create_invite( ) }; + let max_uses: Option = match form.max_uses.as_str() { + "unlimited" => None, + n => Some( + n.parse() + .map_err(|_| WebError::BadRequest("Invalid max uses value".to_string()))?, + ), + }; + let db = state.db.clone(); let code_clone = code.clone(); let user_id = current_user.user_id; web::block(move || { let db = db.lock(); - db.create_invite(&code_clone, Some(user_id), expires_at.as_deref()) + db.create_invite(&code_clone, Some(user_id), expires_at.as_deref(), max_uses) }) .await .map_err(WebError::from)? diff --git a/src/web/invite.rs b/src/web/invite.rs index 58b991b8..87474482 100644 --- a/src/web/invite.rs +++ b/src/web/invite.rs @@ -46,8 +46,11 @@ pub fn validate_invite_code(db: &Database, code: &str) -> Result= max { + return Err(InviteError::AlreadyUsed); + } } if is_invite_expired(invite.expires_at.as_deref()) { diff --git a/src/web/mod.rs b/src/web/mod.rs index 6874a026..55b86bf9 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -921,6 +921,12 @@ pub fn configure_app( quma_scope = quma_scope.service(auth_scope); + // Unrecognized /quma/* paths get a styled 404 instead of falling through + // to the SPT proxy (which would return confusing proxy errors). + quma_scope = quma_scope.default_service(web::to(|| async { + Err::(error::WebError::NotFound.into()) + })); + cfg.service(quma_scope); // Root redirect and default proxy handler diff --git a/templates/admin/partials/invites.html b/templates/admin/partials/invites.html index 40e1459f..4bf2d4ac 100644 --- a/templates/admin/partials/invites.html +++ b/templates/admin/partials/invites.html @@ -3,7 +3,7 @@

Create Invite Code

+ style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap"> + +
@@ -25,7 +33,8 @@

Create Invite Code

Created Expires Status - Used By + Uses + Last Used By Actions @@ -57,6 +66,13 @@

Create Invite Code

Available {% endif %} + + {% if let Some(max) = iv.max_uses %} + {{ iv.use_count }}/{{ max }} + {% else %} + {{ iv.use_count }}/ + {% endif %} + {% if let Some(u) = iv.used_by_username.as_deref() %}{{ u }}{% endif %} {% if iv.status != "used" %} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 055c4638..851ea4fd 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -119,7 +119,7 @@ impl TestAppBuilder { // Seed invites for (code, expires_at) in &self.invites { - db.create_invite(code, None, expires_at.as_deref()) + db.create_invite(code, None, expires_at.as_deref(), Some(1)) .expect("failed to insert invite"); } diff --git a/tests/web_admin.rs b/tests/web_admin.rs index 0a1139b6..a67c3b3b 100644 --- a/tests/web_admin.rs +++ b/tests/web_admin.rs @@ -256,7 +256,7 @@ async fn admin_create_invite() { // Create invite with never expiry let form_body = format!( - "expiry=never&csrf_token={}", + "expiry=never&max_uses=1&csrf_token={}", urlencoding::encode(&csrf_token) ); let resp = app.post_form("/quma/api/admin/invites", &form_body).await;