mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
refactor: remove invitation system
The invitation flow is redundant — admin create user already generates a token and shows a login link. Invitations add complexity without value until email integration exists. Removed: - InvitationRecord struct and 4 UserStore trait methods - invitations table from V14 migration (postgres + both libsql schemas) - PostgreSQL Store methods (create/get/accept/list invitations) - libSQL UserStore invitation methods + row_to_invitation helper - invitations.rs handler file (212 lines) - /api/invitations routes (create, list, accept) - test_invitation_lifecycle test Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -29,15 +29,3 @@ CREATE TABLE api_tokens (
|
||||
);
|
||||
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE invitations (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT, -- nullable for link-based invites
|
||||
invite_token_hash BYTEA NOT NULL, -- SHA-256 hash of the invite link token
|
||||
invited_by TEXT NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | expired | revoked
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
accepted_at TIMESTAMPTZ,
|
||||
accepted_by TEXT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
//! Invitation management API handlers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use rand::RngCore;
|
||||
use rand::rngs::OsRng;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
|
||||
use crate::channels::web::server::GatewayState;
|
||||
use crate::db::{InvitationRecord, UserRecord};
|
||||
|
||||
/// POST /api/invitations — create an invitation (admin only).
|
||||
pub async fn invitations_create_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AdminUser(user): AdminUser,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let email = body.get("email").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
let expires_in_days = body
|
||||
.get("expires_in_days")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(7);
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let expires_at = now + chrono::Duration::days(expires_in_days as i64);
|
||||
|
||||
// Generate 32 random bytes for the invite token.
|
||||
let mut token_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut token_bytes);
|
||||
let plaintext_token = hex::encode(token_bytes);
|
||||
let hash = crate::channels::web::auth::hash_token(&plaintext_token);
|
||||
|
||||
let invitation_id = Uuid::new_v4();
|
||||
let invitation = InvitationRecord {
|
||||
id: invitation_id,
|
||||
email: email.clone(),
|
||||
invited_by: user.user_id.clone(),
|
||||
status: "pending".to_string(),
|
||||
expires_at,
|
||||
accepted_at: None,
|
||||
accepted_by: None,
|
||||
created_at: now,
|
||||
};
|
||||
|
||||
store
|
||||
.create_invitation(&invitation, &hash)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Return the plaintext token — this is the ONLY time it is shown.
|
||||
Ok(Json(serde_json::json!({
|
||||
"invite_token": plaintext_token,
|
||||
"id": invitation_id.to_string(),
|
||||
"email": email,
|
||||
"expires_at": expires_at.to_rfc3339(),
|
||||
"created_at": now.to_rfc3339(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/invitations — list invitations created by the current user.
|
||||
pub async fn invitations_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let invitations = store
|
||||
.list_invitations(Some(&user.user_id))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let invitations_json: Vec<serde_json::Value> = invitations
|
||||
.into_iter()
|
||||
.map(|inv| {
|
||||
serde_json::json!({
|
||||
"id": inv.id.to_string(),
|
||||
"email": inv.email,
|
||||
"invited_by": inv.invited_by,
|
||||
"status": inv.status,
|
||||
"expires_at": inv.expires_at.to_rfc3339(),
|
||||
"accepted_at": inv.accepted_at.map(|dt| dt.to_rfc3339()),
|
||||
"accepted_by": inv.accepted_by,
|
||||
"created_at": inv.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "invitations": invitations_json })))
|
||||
}
|
||||
|
||||
/// POST /api/invitations/accept — accept an invitation and create a user account.
|
||||
pub async fn invitations_accept_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let invite_token = body.get("invite_token").and_then(|v| v.as_str()).ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing required field 'invite_token'".to_string(),
|
||||
))?;
|
||||
|
||||
let display_name = body
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing required field 'display_name'".to_string(),
|
||||
))?
|
||||
.to_string();
|
||||
|
||||
// Hash the provided token to look up the invitation.
|
||||
// Hash the plaintext token string (not decoded bytes) — must match
|
||||
// how it was hashed during invitation creation via hash_token().
|
||||
let hash = crate::channels::web::auth::hash_token(invite_token);
|
||||
|
||||
// Look up the invitation by hash.
|
||||
let invitation = store
|
||||
.get_invitation_by_hash(&hash)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
"Invitation not found or already used".to_string(),
|
||||
))?;
|
||||
|
||||
// Verify the invitation is still pending.
|
||||
if invitation.status != "pending" {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
format!("Invitation is already '{}'", invitation.status),
|
||||
));
|
||||
}
|
||||
|
||||
// Verify the invitation has not expired.
|
||||
if invitation.expires_at < chrono::Utc::now() {
|
||||
return Err((StatusCode::GONE, "Invitation has expired".to_string()));
|
||||
}
|
||||
|
||||
let new_user_id = Uuid::new_v4().to_string();
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let user_record = UserRecord {
|
||||
id: new_user_id.clone(),
|
||||
email: invitation.email.clone(),
|
||||
display_name: display_name.clone(),
|
||||
status: "active".to_string(),
|
||||
role: "member".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_login_at: None,
|
||||
created_by: Some(invitation.invited_by.clone()),
|
||||
metadata: serde_json::json!({}),
|
||||
};
|
||||
|
||||
store
|
||||
.create_user(&user_record)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Create a first API token for the new user.
|
||||
let mut api_token_bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut api_token_bytes);
|
||||
let plaintext_api_token = hex::encode(api_token_bytes);
|
||||
let api_hash = crate::channels::web::auth::hash_token(&plaintext_api_token);
|
||||
|
||||
let api_prefix = &plaintext_api_token[..8];
|
||||
|
||||
let api_token_record = store
|
||||
.create_api_token(&new_user_id, "default", &api_hash, api_prefix, None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Mark the invitation as accepted.
|
||||
store
|
||||
.accept_invitation(invitation.id, &new_user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"user": {
|
||||
"id": new_user_id,
|
||||
"email": user_record.email,
|
||||
"display_name": user_record.display_name,
|
||||
"status": "active",
|
||||
"created_at": now.to_rfc3339(),
|
||||
},
|
||||
"api_token": {
|
||||
"token": plaintext_api_token,
|
||||
"id": api_token_record.id.to_string(),
|
||||
"name": api_token_record.name,
|
||||
"token_prefix": api_token_record.token_prefix,
|
||||
"created_at": api_token_record.created_at.to_rfc3339(),
|
||||
},
|
||||
"invitation_id": invitation.id.to_string(),
|
||||
})))
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! Each module groups related endpoint handlers by domain.
|
||||
|
||||
pub mod invitations;
|
||||
pub mod jobs;
|
||||
pub mod memory;
|
||||
pub mod routines;
|
||||
|
||||
@@ -417,11 +417,6 @@ pub async fn start_server(
|
||||
.route(
|
||||
"/api/webhooks/u/{user_id}/{path}",
|
||||
post(crate::channels::web::handlers::webhooks::webhook_trigger_user_scoped_handler),
|
||||
)
|
||||
// Invitation accept (public — validated by invite token, not user auth)
|
||||
.route(
|
||||
"/api/invitations/accept",
|
||||
post(super::handlers::invitations::invitations_accept_handler),
|
||||
);
|
||||
|
||||
// Protected routes (require auth)
|
||||
@@ -544,12 +539,6 @@ pub async fn start_server(
|
||||
"/api/tokens/{id}",
|
||||
axum::routing::delete(super::handlers::tokens::tokens_revoke_handler),
|
||||
)
|
||||
// Invitations
|
||||
.route(
|
||||
"/api/invitations",
|
||||
get(super::handlers::invitations::invitations_list_handler)
|
||||
.post(super::handlers::invitations::invitations_create_handler),
|
||||
)
|
||||
// Gateway control plane
|
||||
.route("/api/gateway/status", get(gateway_status_handler))
|
||||
// OpenAI-compatible API
|
||||
|
||||
+1
-201
@@ -7,7 +7,7 @@ use uuid::Uuid;
|
||||
|
||||
use super::{fmt_opt_ts, fmt_ts, get_opt_text, get_opt_ts, get_text, get_ts, opt_text};
|
||||
use crate::db::libsql::LibSqlBackend;
|
||||
use crate::db::{ApiTokenRecord, DatabaseError, InvitationRecord, UserRecord, UserStore};
|
||||
use crate::db::{ApiTokenRecord, DatabaseError, UserRecord, UserStore};
|
||||
|
||||
fn row_to_user(row: &libsql::Row) -> Result<UserRecord, DatabaseError> {
|
||||
let metadata_str = get_text(row, 9);
|
||||
@@ -44,23 +44,6 @@ fn row_to_api_token(row: &libsql::Row) -> Result<ApiTokenRecord, DatabaseError>
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_invitation(row: &libsql::Row) -> Result<InvitationRecord, DatabaseError> {
|
||||
let id_str = get_text(row, 0);
|
||||
let id: Uuid = id_str
|
||||
.parse()
|
||||
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
|
||||
Ok(InvitationRecord {
|
||||
id,
|
||||
email: get_opt_text(row, 1),
|
||||
invited_by: get_text(row, 2),
|
||||
status: get_text(row, 3),
|
||||
expires_at: get_ts(row, 4),
|
||||
accepted_at: get_opt_ts(row, 5),
|
||||
accepted_by: get_opt_text(row, 6),
|
||||
created_at: get_ts(row, 7),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserStore for LibSqlBackend {
|
||||
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
|
||||
@@ -388,119 +371,6 @@ impl UserStore for LibSqlBackend {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_invitation(
|
||||
&self,
|
||||
invitation: &InvitationRecord,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO invitations (id, email, invite_token_hash, invited_by, status, expires_at, accepted_at, accepted_by, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
"#,
|
||||
params![
|
||||
invitation.id.to_string(),
|
||||
opt_text(invitation.email.as_deref()),
|
||||
libsql::Value::Blob(invite_hash.to_vec()),
|
||||
invitation.invited_by.as_str(),
|
||||
invitation.status.as_str(),
|
||||
fmt_ts(&invitation.expires_at),
|
||||
fmt_opt_ts(&invitation.accepted_at),
|
||||
opt_text(invitation.accepted_by.as_deref()),
|
||||
fmt_ts(&invitation.created_at),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_invitation_by_hash(
|
||||
&self,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<Option<InvitationRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
WHERE invite_token_hash = ?1
|
||||
AND status = 'pending'
|
||||
AND expires_at > strftime('%Y-%m-%dT%H:%M:%S', 'now')
|
||||
"#,
|
||||
params![libsql::Value::Blob(invite_hash.to_vec())],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
Some(row) => Ok(Some(row_to_invitation(&row)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let now = fmt_ts(&Utc::now());
|
||||
conn.execute(
|
||||
r#"
|
||||
UPDATE invitations SET status = 'accepted', accepted_at = ?2, accepted_by = ?3
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
params![id.to_string(), now, accepted_by],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_invitations(
|
||||
&self,
|
||||
invited_by: Option<&str>,
|
||||
) -> Result<Vec<InvitationRecord>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut invitations = Vec::new();
|
||||
|
||||
let mut rows = if let Some(invited_by) = invited_by {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations WHERE invited_by = ?1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
params![invited_by],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
} else {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
};
|
||||
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
invitations.push(row_to_invitation(&row)?);
|
||||
}
|
||||
Ok(invitations)
|
||||
}
|
||||
|
||||
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
@@ -694,76 +564,6 @@ mod tests {
|
||||
assert!(db.revoke_api_token(record.id, "alice").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invitation_lifecycle() {
|
||||
let (db, _dir) = setup().await;
|
||||
db.create_user(&test_user("alice")).await.unwrap();
|
||||
|
||||
// Create invitation
|
||||
let invite_hash = hash("invite-token-xyz");
|
||||
let invitation = InvitationRecord {
|
||||
id: Uuid::new_v4(),
|
||||
email: Some("[email protected]".to_string()),
|
||||
invited_by: "alice".to_string(),
|
||||
status: "pending".to_string(),
|
||||
expires_at: Utc::now() + chrono::Duration::days(7),
|
||||
accepted_at: None,
|
||||
accepted_by: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.create_invitation(&invitation, &invite_hash)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Look up by hash
|
||||
let found = db
|
||||
.get_invitation_by_hash(&invite_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(found.id, invitation.id);
|
||||
assert_eq!(found.status, "pending");
|
||||
|
||||
// List invitations
|
||||
let list = db.list_invitations(Some("alice")).await.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
|
||||
// Accept
|
||||
db.create_user(&UserRecord {
|
||||
id: "newuser".to_string(),
|
||||
email: Some("[email protected]".to_string()),
|
||||
display_name: "New User".to_string(),
|
||||
status: "active".to_string(),
|
||||
role: "member".to_string(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
created_by: Some("alice".to_string()),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
db.accept_invitation(invitation.id, "newuser")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// After acceptance, the invitation should no longer be found via hash
|
||||
// lookup (which filters for status='pending')
|
||||
assert!(
|
||||
db.get_invitation_by_hash(&invite_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"Accepted invitation should not be returned by pending-only lookup"
|
||||
);
|
||||
|
||||
// Verify via list that it was accepted
|
||||
let all = db.list_invitations(Some("alice")).await.unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].status, "accepted");
|
||||
assert_eq!(all[0].accepted_by, Some("newuser".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_record_login_and_token_usage() {
|
||||
let (db, _dir) = setup().await;
|
||||
|
||||
@@ -609,18 +609,6 @@ CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
invite_token_hash BLOB NOT NULL,
|
||||
invited_by TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
accepted_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
"#;
|
||||
|
||||
/// Incremental migrations applied after the base schema.
|
||||
@@ -797,18 +785,6 @@ CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
invite_token_hash BLOB NOT NULL,
|
||||
invited_by TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
accepted_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -346,20 +346,6 @@ pub struct ApiTokenRecord {
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// A pending invitation to create an account.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InvitationRecord {
|
||||
pub id: Uuid,
|
||||
pub email: Option<String>,
|
||||
pub invited_by: String,
|
||||
/// `pending`, `accepted`, `expired`, or `revoked`.
|
||||
pub status: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub accepted_at: Option<DateTime<Utc>>,
|
||||
pub accepted_by: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ==================== Sub-traits ====================
|
||||
//
|
||||
// Each sub-trait groups related persistence methods. The `Database` supertrait
|
||||
@@ -860,26 +846,6 @@ pub trait UserStore: Send + Sync {
|
||||
/// Update `last_used_at` for a token.
|
||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError>;
|
||||
|
||||
// ---- Invitations ----
|
||||
|
||||
/// Create a new invitation.
|
||||
async fn create_invitation(
|
||||
&self,
|
||||
invitation: &InvitationRecord,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<(), DatabaseError>;
|
||||
/// Look up a pending invitation by its hashed token.
|
||||
async fn get_invitation_by_hash(
|
||||
&self,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<Option<InvitationRecord>, DatabaseError>;
|
||||
/// Accept an invitation (sets status=accepted, accepted_by, accepted_at).
|
||||
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError>;
|
||||
/// List invitations, optionally filtered by inviter.
|
||||
async fn list_invitations(
|
||||
&self,
|
||||
invited_by: Option<&str>,
|
||||
) -> Result<Vec<InvitationRecord>, DatabaseError>;
|
||||
/// Check whether any user records exist (for first-run bootstrap detection).
|
||||
async fn has_any_users(&self) -> Result<bool, DatabaseError>;
|
||||
}
|
||||
|
||||
+2
-28
@@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus};
|
||||
use crate::config::DatabaseConfig;
|
||||
use crate::context::{ActionRecord, JobContext, JobState};
|
||||
use crate::db::{
|
||||
ApiTokenRecord, ConversationStore, Database, InvitationRecord, JobStore, RoutineStore,
|
||||
SandboxStore, SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
|
||||
ApiTokenRecord, ConversationStore, Database, JobStore, RoutineStore, SandboxStore,
|
||||
SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
|
||||
};
|
||||
use crate::error::{DatabaseError, WorkspaceError};
|
||||
use crate::history::{
|
||||
@@ -858,32 +858,6 @@ impl UserStore for PgBackend {
|
||||
self.store.record_token_usage(token_id).await
|
||||
}
|
||||
|
||||
async fn create_invitation(
|
||||
&self,
|
||||
invitation: &InvitationRecord,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<(), DatabaseError> {
|
||||
self.store.create_invitation(invitation, invite_hash).await
|
||||
}
|
||||
|
||||
async fn get_invitation_by_hash(
|
||||
&self,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<Option<InvitationRecord>, DatabaseError> {
|
||||
self.store.get_invitation_by_hash(invite_hash).await
|
||||
}
|
||||
|
||||
async fn accept_invitation(&self, id: Uuid, accepted_by: &str) -> Result<(), DatabaseError> {
|
||||
self.store.accept_invitation(id, accepted_by).await
|
||||
}
|
||||
|
||||
async fn list_invitations(
|
||||
&self,
|
||||
invited_by: Option<&str>,
|
||||
) -> Result<Vec<InvitationRecord>, DatabaseError> {
|
||||
self.store.list_invitations(invited_by).await
|
||||
}
|
||||
|
||||
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
||||
self.store.has_any_users().await
|
||||
}
|
||||
|
||||
+1
-111
@@ -2282,7 +2282,7 @@ impl Store {
|
||||
// ==================== Users / API Tokens / Invitations ====================
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
use crate::db::{ApiTokenRecord, InvitationRecord, UserRecord};
|
||||
use crate::db::{ApiTokenRecord, UserRecord};
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
impl Store {
|
||||
@@ -2525,102 +2525,6 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new invitation.
|
||||
pub async fn create_invitation(
|
||||
&self,
|
||||
invitation: &InvitationRecord,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO invitations (id, email, invite_token_hash, invited_by, status, expires_at, accepted_at, accepted_by, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
"#,
|
||||
&[
|
||||
&invitation.id,
|
||||
&invitation.email,
|
||||
&invite_hash.to_vec(),
|
||||
&invitation.invited_by,
|
||||
&invitation.status,
|
||||
&invitation.expires_at,
|
||||
&invitation.accepted_at,
|
||||
&invitation.accepted_by,
|
||||
&invitation.created_at,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a pending invitation by its hashed token.
|
||||
pub async fn get_invitation_by_hash(
|
||||
&self,
|
||||
invite_hash: &[u8; 32],
|
||||
) -> Result<Option<InvitationRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
WHERE invite_token_hash = $1 AND status = 'pending' AND expires_at > NOW()
|
||||
"#,
|
||||
&[&invite_hash.to_vec()],
|
||||
)
|
||||
.await?;
|
||||
Ok(row.map(|r| row_to_invitation(&r)))
|
||||
}
|
||||
|
||||
/// Accept an invitation.
|
||||
pub async fn accept_invitation(
|
||||
&self,
|
||||
id: Uuid,
|
||||
accepted_by: &str,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
conn.execute(
|
||||
"UPDATE invitations SET status = 'accepted', accepted_at = NOW(), accepted_by = $1 WHERE id = $2",
|
||||
&[&accepted_by, &id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List invitations, optionally filtered by inviter.
|
||||
pub async fn list_invitations(
|
||||
&self,
|
||||
invited_by: Option<&str>,
|
||||
) -> Result<Vec<InvitationRecord>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let rows = match invited_by {
|
||||
Some(user) => {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
WHERE invited_by = $1
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
&[&user],
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
conn.query(
|
||||
r#"
|
||||
SELECT id, email, invited_by, status, expires_at, accepted_at, accepted_by, created_at
|
||||
FROM invitations
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
&[],
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows.iter().map(row_to_invitation).collect())
|
||||
}
|
||||
|
||||
/// Check whether any user records exist.
|
||||
pub async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
@@ -2664,20 +2568,6 @@ fn row_to_api_token(row: &tokio_postgres::Row) -> ApiTokenRecord {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
fn row_to_invitation(row: &tokio_postgres::Row) -> InvitationRecord {
|
||||
InvitationRecord {
|
||||
id: row.get("id"),
|
||||
email: row.get("email"),
|
||||
invited_by: row.get("invited_by"),
|
||||
status: row.get("status"),
|
||||
expires_at: row.get("expires_at"),
|
||||
accepted_at: row.get("accepted_at"),
|
||||
accepted_by: row.get("accepted_by"),
|
||||
created_at: row.get("created_at"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user