Skip to main content

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-ByClaude Opus 4.6 (1M context) <noreply@anthropic.com>

Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-23 03:53 UTC

Commit:

1094cf7659ce6cce4338b1a73caad856f8aa1a3d

Parent:

0a038d8

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 partials
129
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 tab
376
#[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 dashboard
376
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 members
213
.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 routes
213
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 API
473
// =============================================================================
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 project
484
#[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 percent
498
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 username
503
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 yourself
509
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 refreshes
525
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 member
534
#[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_members
421
.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
}