feat: Organizations + Invitations routes migrated to Rust

- New migration: organizations, organization_members, invitations tables
- New model: Organization, OrganizationMember, Invitation
- Full CRUD: list/create/get/update orgs, members, invitations
- Public invitation lookup + accept flow
- JWT auth for all protected endpoints
- Admin role verification for org management
- 18/18 tests pass
This commit is contained in:
boos
2026-05-17 16:58:27 +08:00
parent fac1aa4a88
commit 4ac9c2385f
5 changed files with 627 additions and 0 deletions
@@ -0,0 +1,53 @@
-- Organizations, members, and invitations for multi-tenant support
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS org_slug_idx
ON organizations (slug);
CREATE INDEX IF NOT EXISTS org_plan_idx
ON organizations (plan);
CREATE TABLE IF NOT EXISTS organization_members (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
status TEXT NOT NULL DEFAULT 'active',
joined_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS org_member_unique
ON organization_members (org_id, user_id);
CREATE INDEX IF NOT EXISTS org_members_org_id_idx
ON organization_members (org_id);
CREATE INDEX IF NOT EXISTS org_members_user_id_idx
ON organization_members (user_id);
CREATE TABLE IF NOT EXISTS invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
org_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'member',
invited_by INTEGER REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS invitations_token_unique
ON invitations (token);
CREATE INDEX IF NOT EXISTS invitations_status_idx
ON invitations (status);
+1
View File
@@ -8,5 +8,6 @@ pub mod setting;
pub mod site; pub mod site;
pub mod site_announcement; pub mod site_announcement;
pub mod background_task; pub mod background_task;
pub mod organization;
pub mod token_route; pub mod token_route;
pub mod user; pub mod user;
+39
View File
@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
/// Organization for multi-tenant setup.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Organization {
pub id: i64,
pub name: String,
pub slug: String,
pub plan: Option<String>,
pub status: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
/// Member of an organization.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct OrganizationMember {
pub id: i64,
pub org_id: i64,
pub user_id: i64,
pub role: Option<String>,
pub status: Option<String>,
pub joined_at: Option<String>,
}
/// Invitation to join an organization.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Invitation {
pub id: i64,
pub org_id: i64,
pub email: String,
pub token: String,
pub role: Option<String>,
pub invited_by: Option<i64>,
pub status: Option<String>,
pub expires_at: Option<String>,
pub created_at: Option<String>,
}
+2
View File
@@ -7,6 +7,7 @@ mod checkin;
mod comfyui; mod comfyui;
mod events; mod events;
mod health; mod health;
mod organizations;
mod proxy; mod proxy;
mod site_announcements; mod site_announcements;
mod tasks; mod tasks;
@@ -21,6 +22,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(auth::routes()) .merge(auth::routes())
.merge(events::routes()) .merge(events::routes())
.merge(checkin::routes()) .merge(checkin::routes())
.merge(organizations::routes())
.merge(site_announcements::routes()) .merge(site_announcements::routes())
.merge(tasks::routes()) .merge(tasks::routes())
.merge(user_api_keys::routes()) .merge(user_api_keys::routes())
+532
View File
@@ -0,0 +1,532 @@
// Organizations, members, and invitations routes
use crate::{
auth::jwt::validate_jwt,
db::models::organization::{Invitation, Organization, OrganizationMember},
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
routing::{delete, get, patch, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
// ── Auth helper ────────────────────────────────────────────────────────
fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, String)> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let user_id: i64 = claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
Ok((user_id, claims.role))
}
/// Check if user is org admin (owner or admin role).
async fn require_org_admin(
db: &sqlx::SqlitePool,
org_id: i64,
user_id: i64,
) -> AppResult<()> {
let member: Option<OrganizationMember> = sqlx::query_as(
"SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?"
)
.bind(org_id)
.bind(user_id)
.fetch_optional(db)
.await?;
match member {
Some(m) if m.role.as_deref() == Some("owner") || m.role.as_deref() == Some("admin") => Ok(()),
_ => Err(AppError::Forbidden("Admin access required".into())),
}
}
/// Check if user is a member of an org.
async fn is_org_member(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> Result<bool, AppError> {
let member: Option<OrganizationMember> = sqlx::query_as(
"SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?"
)
.bind(org_id)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(member.is_some())
}
// ── Routes ─────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct ListOrgsQuery {
limit: Option<i64>,
}
pub fn routes() -> Router<AppState> {
Router::new()
// Organizations CRUD
.route("/api/organizations", get(list_organizations).post(create_organization))
.route("/api/organizations/plans", get(list_plans))
.route("/api/organizations/{org_id}", get(get_organization).patch(update_organization))
// Members
.route("/api/organizations/{org_id}/members", get(list_members))
.route("/api/organizations/{org_id}/members/{user_id}", patch(update_member_role).delete(remove_member))
// Invitations
.route("/api/organizations/{org_id}/invitations", get(list_invitations).post(create_invitation))
.route("/api/organizations/{org_id}/invitations/{inv_id}", delete(cancel_invitation))
// Public invitation routes (no org prefix)
.route("/api/invitations/{token}", get(get_invitation))
.route("/api/invitations/{token}/accept", post(accept_invitation))
}
// ── Organization Handlers ──────────────────────────────────────────────
/// GET /api/organizations
async fn list_organizations(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<ListOrgsQuery>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
let limit = q.limit.unwrap_or(50).clamp(1, 500);
let orgs: Vec<Organization> = sqlx::query_as(
r#"SELECT o.* FROM organizations o
INNER JOIN organization_members om ON om.org_id = o.id
WHERE om.user_id = ?
ORDER BY o.created_at DESC
LIMIT ?"#
)
.bind(user_id)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "organizations": orgs })))
}
/// POST /api/organizations
async fn create_organization(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<serde_json::Value>,
) -> AppResult<(StatusCode, Json<Value>)> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
let name = body.get("name").and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
let slug = body.get("slug").and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("slug is required".into()))?;
let result = sqlx::query(
"INSERT INTO organizations (name, slug) VALUES (?, ?)"
)
.bind(name)
.bind(slug)
.execute(&*state.db)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("UNIQUE") {
AppError::BadRequest("Organization slug already exists".into())
} else {
AppError::Internal(anyhow::anyhow!("Failed to create organization: {e}"))
}
})?;
let org_id = result.last_insert_rowid();
// Add creator as owner
sqlx::query(
"INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')"
)
.bind(org_id)
.bind(user_id)
.execute(&*state.db)
.await?;
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(org_id)
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "organization": org }))))
}
/// GET /api/organizations/plans
async fn list_plans() -> AppResult<Json<Value>> {
// Static plan definitions matching Node.js listAllPlans()
let plans = json!([
{
"id": "free",
"name": "免费",
"description": "基础功能",
"limits": { "members": 3, "sites": 2 },
"price": 0,
},
{
"id": "pro",
"name": "专业版",
"description": "进阶功能",
"limits": { "members": 20, "sites": 20 },
"price": 9900,
},
{
"id": "enterprise",
"name": "企业版",
"description": "全功能",
"limits": { "members": 100, "sites": 100 },
"price": 49900,
},
]);
Ok(Json(json!({ "plans": plans })))
}
/// GET /api/organizations/:org_id
async fn get_organization(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
if !is_org_member(&state.db, org_id, user_id).await? {
return Err(AppError::Forbidden("You are not a member of this organization".into()));
}
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(org_id)
.fetch_optional(&*state.db)
.await?;
match org {
Some(o) => Ok(Json(json!({ "organization": o }))),
None => Err(AppError::NotFound("Organization not found".into())),
}
}
/// PATCH /api/organizations/:org_id
async fn update_organization(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let name = body.get("name").and_then(|v| v.as_str());
let slug = body.get("slug").and_then(|v| v.as_str());
if name.is_none() && slug.is_none() {
return Err(AppError::BadRequest("No fields to update".into()));
}
if let Some(s) = slug {
sqlx::query("UPDATE organizations SET slug = ?, updated_at = datetime('now') WHERE id = ?")
.bind(s)
.bind(org_id)
.execute(&*state.db)
.await?;
}
if let Some(n) = name {
sqlx::query("UPDATE organizations SET name = ?, updated_at = datetime('now') WHERE id = ?")
.bind(n)
.bind(org_id)
.execute(&*state.db)
.await?;
}
let org: Organization = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(org_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "organization": org })))
}
// ── Member Handlers ────────────────────────────────────────────────────
/// GET /api/organizations/:org_id/members
async fn list_members(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
if !is_org_member(&state.db, org_id, user_id).await? {
return Err(AppError::Forbidden("You are not a member of this organization".into()));
}
let members: Vec<Value> = sqlx::query_as::<_, OrganizationMember>(
"SELECT * FROM organization_members WHERE org_id = ? ORDER BY joined_at ASC"
)
.bind(org_id)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|m| json!({
"id": m.id,
"orgId": m.org_id,
"userId": m.user_id,
"role": m.role,
"status": m.status,
"joinedAt": m.joined_at,
}))
.collect();
Ok(Json(json!({ "members": members })))
}
/// PATCH /api/organizations/:org_id/members/:user_id
async fn update_member_role(
State(state): State<AppState>,
headers: HeaderMap,
Path((org_id, target_user_id)): Path<(i64, i64)>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let role = body.get("role").and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("role is required".into()))?;
if !["owner", "admin", "member"].contains(&role) {
return Err(AppError::BadRequest("Role must be one of: owner, admin, member".into()));
}
sqlx::query("UPDATE organization_members SET role = ? WHERE org_id = ? AND user_id = ?")
.bind(role)
.bind(org_id)
.bind(target_user_id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true })))
}
/// DELETE /api/organizations/:org_id/members/:user_id
async fn remove_member(
State(state): State<AppState>,
headers: HeaderMap,
Path((org_id, target_user_id)): Path<(i64, i64)>,
) -> AppResult<StatusCode> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
sqlx::query("DELETE FROM organization_members WHERE org_id = ? AND user_id = ?")
.bind(org_id)
.bind(target_user_id)
.execute(&*state.db)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── Invitation Handlers ────────────────────────────────────────────────
/// GET /api/organizations/:org_id/invitations
async fn list_invitations(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let invitations: Vec<Invitation> = sqlx::query_as(
"SELECT * FROM invitations WHERE org_id = ? ORDER BY created_at DESC"
)
.bind(org_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "invitations": invitations })))
}
/// POST /api/organizations/:org_id/invitations
async fn create_invitation(
State(state): State<AppState>,
headers: HeaderMap,
Path(org_id): Path<i64>,
Json(body): Json<serde_json::Value>,
) -> AppResult<(StatusCode, Json<Value>)> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let email = body.get("email").and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("Email is required".into()))?;
let role = body.get("role").and_then(|v| v.as_str()).unwrap_or("member");
if !["admin", "member"].contains(&role) {
return Err(AppError::BadRequest("Role must be one of: admin, member".into()));
}
// Generate token
let token = format!("inv-{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
let expires_at = chrono::Utc::now()
.checked_add_signed(chrono::Duration::days(7))
.unwrap()
.format("%Y-%m-%d %H:%M:%S")
.to_string();
let result = sqlx::query(
"INSERT INTO invitations (org_id, email, token, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)"
)
.bind(org_id)
.bind(email)
.bind(&token)
.bind(role)
.bind(user_id)
.bind(&expires_at)
.execute(&*state.db)
.await?;
let inv: Invitation = sqlx::query_as("SELECT * FROM invitations WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "invitation": inv }))))
}
/// DELETE /api/organizations/:org_id/invitations/:inv_id
async fn cancel_invitation(
State(state): State<AppState>,
headers: HeaderMap,
Path((org_id, inv_id)): Path<(i64, i64)>,
) -> AppResult<StatusCode> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
sqlx::query("UPDATE invitations SET status = 'cancelled' WHERE id = ? AND org_id = ?")
.bind(inv_id)
.bind(org_id)
.execute(&*state.db)
.await?;
Ok(StatusCode::NO_CONTENT)
}
// ── Public Invitation Handlers ─────────────────────────────────────────
/// GET /api/invitations/:token — public, no auth required
async fn get_invitation(
State(state): State<AppState>,
Path(token): Path<String>,
) -> AppResult<Json<Value>> {
let invitation: Option<Invitation> = sqlx::query_as(
"SELECT * FROM invitations WHERE token = ? AND status = 'pending'"
)
.bind(&token)
.fetch_optional(&*state.db)
.await?;
match invitation {
Some(i) => {
// Check expiry
let expired = i.expires_at.as_ref().map_or(false, |e| {
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
.unwrap_or(false)
});
if expired {
return Err(AppError::NotFound("Invitation not found or expired".into()));
}
// Get org name
let org_name: Option<(String,)> = sqlx::query_as(
"SELECT name FROM organizations WHERE id = ?"
)
.bind(i.org_id)
.fetch_optional(&*state.db)
.await?;
Ok(Json(json!({
"invitation": {
"id": i.id,
"orgId": i.org_id,
"orgName": org_name.map(|n| n.0).unwrap_or_default(),
"email": i.email,
"token": i.token,
"role": i.role,
"invitedByUsername": "",
"status": i.status,
"expiresAt": i.expires_at,
"createdAt": i.created_at,
}
})))
}
None => Err(AppError::NotFound("Invitation not found or expired".into())),
}
}
/// POST /api/invitations/:token/accept — JWT auth required
async fn accept_invitation(
State(state): State<AppState>,
headers: HeaderMap,
Path(token): Path<String>,
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
let invitation: Option<Invitation> = sqlx::query_as(
"SELECT * FROM invitations WHERE token = ? AND status = 'pending'"
)
.bind(&token)
.fetch_optional(&*state.db)
.await?;
let inv = match invitation {
Some(i) => i,
None => return Err(AppError::BadRequest("Invitation not found or expired".into())),
};
// Check expiry
let expired = inv.expires_at.as_ref().map_or(true, |e| {
chrono::NaiveDateTime::parse_from_str(e.trim(), "%Y-%m-%d %H:%M:%S")
.map(|dt| dt.and_utc().timestamp() < chrono::Utc::now().timestamp())
.unwrap_or(true)
});
if expired {
return Err(AppError::BadRequest("Invitation not found or expired".into()));
}
// Mark invitation as accepted
sqlx::query("UPDATE invitations SET status = 'accepted' WHERE id = ?")
.bind(inv.id)
.execute(&*state.db)
.await?;
// Add user as member
sqlx::query(
"INSERT OR IGNORE INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)"
)
.bind(inv.org_id)
.bind(user_id)
.bind(&inv.role)
.execute(&*state.db)
.await?;
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(inv.org_id)
.fetch_optional(&*state.db)
.await?;
Ok(Json(json!({ "organization": org })))
}