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
7 changes: 7 additions & 0 deletions migrations/020_multi_use_invites.sql
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions src/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion src/cli/invite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down
28 changes: 19 additions & 9 deletions src/db/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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));
Expand All @@ -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();
Expand Down
31 changes: 22 additions & 9 deletions src/db/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ pub struct InviteCode {
pub created_at: String,
pub used_at: Option<String>,
pub expires_at: Option<String>,
pub max_uses: Option<i64>,
pub use_count: i64,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -325,18 +327,19 @@ impl Database {
code: &str,
created_by: Option<i64>,
expires_at: Option<&str>,
max_uses: Option<i64>,
) -> rusqlite::Result<i64> {
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())
}

pub fn get_invite(&self, code: &str) -> rusqlite::Result<Option<InviteCode>> {
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,
Expand All @@ -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<usize> {
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],
)
Expand All @@ -358,6 +362,7 @@ impl Database {
pub fn list_invite_codes(&self) -> rusqlite::Result<Vec<InviteCodeWithUsers>> {
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
Expand All @@ -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<DeleteInviteResult> {
// 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 {
Expand Down Expand Up @@ -599,6 +610,8 @@ fn row_to_invite_code(row: &rusqlite::Row<'_>) -> rusqlite::Result<InviteCode> {
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)?,
})
}

Expand Down
21 changes: 19 additions & 2 deletions src/web/handlers/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,18 @@ pub struct InviteView {
pub used_by_username: Option<String>,
pub created_at: String,
pub expires_at: Option<String>,
pub max_uses: Option<i64>,
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"
Expand All @@ -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(),
}
}
Expand Down Expand Up @@ -199,6 +207,7 @@ pub struct CsrfOnly {
#[derive(serde::Deserialize)]
pub struct InviteForm {
expiry: String,
max_uses: String,
csrf_token: String,
}

Expand Down Expand Up @@ -662,12 +671,20 @@ pub async fn create_invite(
)
};

let max_uses: Option<i64> = 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)?
Expand Down
7 changes: 5 additions & 2 deletions src/web/invite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ pub fn validate_invite_code(db: &Database, code: &str) -> Result<InviteCode, Inv
})?
.ok_or(InviteError::NotFound)?;

if invite.used_by.is_some() {
return Err(InviteError::AlreadyUsed);
// Check if all uses are exhausted (max_uses NULL = unlimited)
if let Some(max) = invite.max_uses {
if invite.use_count >= max {
return Err(InviteError::AlreadyUsed);
}
}

if is_invite_expired(invite.expires_at.as_deref()) {
Expand Down
6 changes: 6 additions & 0 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<HttpResponse, actix_web::Error>(error::WebError::NotFound.into())
}));

cfg.service(quma_scope);

// Root redirect and default proxy handler
Expand Down
20 changes: 18 additions & 2 deletions templates/admin/partials/invites.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ <h3>Create Invite Code</h3>
<form hx-post="/quma/api/admin/invites"
hx-target="#admin-content"
hx-swap="innerHTML"
style="display:flex;gap:0.5rem;align-items:center">
style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="expiry">Expires in:</label>
<select name="expiry" id="expiry">
Expand All @@ -13,6 +13,14 @@ <h3>Create Invite Code</h3>
<option value="30d">30 days</option>
<option value="never">Never</option>
</select>
<label for="max_uses">Uses:</label>
<select name="max_uses" id="max_uses">
<option value="1" selected>Single use</option>
<option value="5">5 uses</option>
<option value="10">10 uses</option>
<option value="25">25 uses</option>
<option value="unlimited">Unlimited</option>
</select>
<button type="submit" class="btn" hx-disabled-elt="this">Create</button>
</form>
</div>
Expand All @@ -25,7 +33,8 @@ <h3>Create Invite Code</h3>
<th>Created</th>
<th>Expires</th>
<th>Status</th>
<th>Used By</th>
<th>Uses</th>
<th>Last Used By</th>
<th>Actions</th>
</tr>
</thead>
Expand Down Expand Up @@ -57,6 +66,13 @@ <h3>Create Invite Code</h3>
<span class="badge badge-success">Available</span>
{% endif %}
</td>
<td class="text-sm">
{% if let Some(max) = iv.max_uses %}
{{ iv.use_count }}/{{ max }}
{% else %}
{{ iv.use_count }}/<span title="Unlimited">&#8734;</span>
{% endif %}
</td>
<td>{% if let Some(u) = iv.used_by_username.as_deref() %}<a href="/quma/profiles/{{ u }}">{{ u }}</a>{% endif %}</td>
<td>
{% if iv.status != "used" %}
Expand Down
2 changes: 1 addition & 1 deletion tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
2 changes: 1 addition & 1 deletion tests/web_admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading