Add project members tab with revenue split management - Members tab on project dashboard with add/remove UI - Add member by username with split percentage and optional role - Owner's share auto-calculated as remainder (100% - member splits) - Stripe connection status shown per member - API endpoints: POST/DELETE /api/projects/{id}/members - ProjectMembersTabTemplate, ProjectMemberRow view type - Cache generation bumped on member changes for HTMX refresh
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-23 03:53 UTC
Commit:
1094cf7659ce6cce4338b1a73caad856f8aa1a3dParent:
9 files changed,
+263 insertions,
-1 deletion
OldNewLine
@@ -124,6 +124,7 @@
124
124
ProjectCodeTabTemplate,125
125
ProjectBlogTabTemplate,126
126
ProjectSubscriptionsTabTemplate,127
ProjectMembersTabTemplate,127
128
ItemEditRowTemplate,128
129
// Admin partials129
130
AdminWaitlistEntriesTemplate,OldNewLine
@@ -324,6 +324,17 @@
324
324
pub stripe_connected: bool,325
325
}326
326
327
/// Dashboard members tab partial for managing project members and revenue splits.328
#[derive(Template)]329
#[template(path = "partials/tabs/project_members.html")]330
#[allow(dead_code)]331
pub struct ProjectMembersTabTemplate {332
pub project_id: String,333
pub project_slug: String,334
pub members: Vec<ProjectMemberRow>,335
pub owner_split: i64,336
}337
327
338
/// SyncKit tab in the user dashboard for managing sync apps.328
339
#[derive(Template)]329
340
#[template(path = "partials/tabs/user_synckit.html")]OldNewLine
@@ -372,6 +372,19 @@
372
372
pub message: Option<String>,373
373
}374
374
375
/// Project member row for dashboard members tab376
#[derive(Clone)]377
pub struct ProjectMemberRow {378
pub id: String,379
pub user_id: String,380
pub username: String,381
pub display_name: Option<String>,382
pub role: String,383
pub split_percent: i16,384
pub stripe_connected: bool,385
pub added_at: String,386
}387
375
388
/// Project card for dashboard376
389
#[derive(Clone)]377
390
pub struct ProjectCard {OldNewLine
@@ -99,6 +99,16 @@
99
99
hx-indicator="#tab-spinner"100
100
onclick="setActiveTab(this)">Subscriptions</button>101
101
{% endif %}102
<button class="tab"103
role="tab"104
aria-selected="false"105
aria-controls="tab-content"106
id="tab-members"107
hx-get="/dashboard/project/{{ project.slug }}/tabs/members"108
hx-target="#tab-content"109
hx-swap="innerHTML"110
hx-indicator="#tab-spinner"111
onclick="setActiveTab(this)">Members</button>102
112
{% if git_enabled %}103
113
<button class="tab"104
114
role="tab"OldNewLine
@@ -209,6 +209,9 @@
209
209
.route("/api/repos/{id}/visibility", put(projects::update_repo_visibility))210
210
.route("/api/projects/{id}/repos", post(projects::link_repo))211
211
.route("/api/projects/{id}/repos/{repo_name}", delete(projects::unlink_repo))212
// Project members213
.route("/api/projects/{id}/members", post(projects::add_project_member))214
.route("/api/projects/{project_id}/members/{user_id}", delete(projects::remove_project_member))212
215
// Item routes213
216
.route("/api/projects/{id}/items", post(items::create_item))214
217
.route("/api/items/{id}", put(items::update_item))OldNewLine
@@ -10,7 +10,7 @@
10
10
11
11
use crate::{12
12
auth::AuthUser,13
db::{self, GitRepoId, ProjectId, ProjectType, Slug},13
db::{self, GitRepoId, ProjectId, ProjectType, Slug, Username},14
14
error::{AppError, Result},15
15
helpers::{htmx_toast_response, is_htmx_request},16
16
types::ListResponse,@@ -467,3 +467,93 @@
467
467
468
468
Ok(htmx_toast_response("Visibility updated", "success"))469
469
}470
471
// =============================================================================472
// Project Members API473
// =============================================================================474
475
/// Form input for adding a project member.476
#[derive(Debug, Deserialize)]477
pub struct AddMemberForm {478
pub username: String,479
pub split_percent: i16,480
pub role: Option<String>,481
}482
483
/// POST /api/projects/{id}/members - Add a member to a project484
#[tracing::instrument(skip_all, name = "api::add_project_member")]485
pub async fn add_project_member(486
State(state): State<AppState>,487
AuthUser(session_user): AuthUser,488
Path(project_id): Path<String>,489
Form(form): Form<AddMemberForm>,490
) -> Result<Response> {491
let project_id: ProjectId = project_id.parse::<uuid::Uuid>()492
.map(ProjectId::from)493
.map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;494
495
let project = verify_project_ownership(&state, project_id, session_user.id).await?;496
497
// Validate split percent498
if form.split_percent < 1 || form.split_percent > 99 {499
return Err(AppError::Validation("Split must be between 1% and 99%".to_string()));500
}501
502
// Look up the member by username503
let username = db::Username::from_trusted(form.username.clone());504
let member_user = db::users::get_user_by_username(&state.db, &username)505
.await?506
.ok_or_else(|| AppError::Validation(format!("User '{}' not found", form.username)))?;507
508
// Can't add yourself509
if member_user.id == session_user.id {510
return Err(AppError::Validation("You are already the project owner".to_string()));511
}512
513
let role = form.role.as_deref().unwrap_or("member");514
515
db::project_members::add_project_member(516
&state.db,517
project_id,518
member_user.id,519
role,520
form.split_percent,521
session_user.id,522
).await?;523
524
// Bump cache generation so the tab refreshes525
db::projects::bump_cache_generation(&state.db, project_id).await?;526
527
Ok(htmx_toast_response(528
&format!("Added @{} with {}% split", member_user.username, form.split_percent),529
"success",530
).into_response())531
}532
533
/// DELETE /api/projects/{project_id}/members/{user_id} - Remove a member534
#[tracing::instrument(skip_all, name = "api::remove_project_member")]535
pub async fn remove_project_member(536
State(state): State<AppState>,537
AuthUser(session_user): AuthUser,538
Path((project_id, user_id)): Path<(String, String)>,539
) -> Result<Response> {540
let project_id: ProjectId = project_id.parse::<uuid::Uuid>()541
.map(ProjectId::from)542
.map_err(|_| AppError::BadRequest("Invalid project ID".to_string()))?;543
544
let user_id: db::UserId = user_id.parse::<uuid::Uuid>()545
.map(db::UserId::from)546
.map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?;547
548
verify_project_ownership(&state, project_id, session_user.id).await?;549
550
let removed = db::project_members::remove_project_member(&state.db, project_id, user_id).await?;551
552
if !removed {553
return Err(AppError::NotFound);554
}555
556
db::projects::bump_cache_generation(&state.db, project_id).await?;557
558
Ok(htmx_toast_response("Member removed", "success").into_response())559
}OldNewLine
@@ -63,6 +63,7 @@
63
63
.route("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog))64
64
.route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions))65
65
.route("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions))66
.route("/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members))66
67
.route("/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview))67
68
.route("/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details))68
69
.route("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing))OldNewLine
@@ -402,3 +402,42 @@
402
402
project_id: db_project.id.to_string(),403
403
}))404
404
}405
406
/// Render the HTMX partial for the project members tab.407
#[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")]408
pub(super) async fn project_tab_members(409
State(state): State<AppState>,410
AuthUser(session_user): AuthUser,411
headers: HeaderMap,412
Path(slug): Path<String>,413
) -> Result<axum::response::Response> {414
let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {415
Ok(pair) => pair,416
Err(not_modified) => return Ok(not_modified),417
};418
419
let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?;420
let members: Vec<ProjectMemberRow> = db_members421
.iter()422
.map(|m| ProjectMemberRow {423
id: m.id.to_string(),424
user_id: m.user_id.to_string(),425
username: m.username.clone(),426
display_name: m.display_name.clone(),427
role: m.role.clone(),428
split_percent: m.split_percent,429
stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled,430
added_at: m.added_at.format("%Y-%m-%d").to_string(),431
})432
.collect();433
434
let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?;435
let owner_split = 100 - total_member_split;436
437
Ok(helpers::with_etag(generation, ProjectMembersTabTemplate {438
project_id: db_project.id.to_string(),439
project_slug: db_project.slug.to_string(),440
members,441
owner_split,442
}))443
}