Add User model with billing types and project membership management

- Backend: User and ProjectUser models with core/collaborator/standing billing types
- Backend: User CRUD routes, project-scoped user routes, audit history endpoint
- Backend: 409 confirmation flow when removing/downgrading core users not covered elsewhere
- Frontend: Users list page, user detail page with project memberships and history
- Frontend: Add/remove users from projects on both project detail and user detail pages
- Frontend: ConfirmDialog success variant (green) for add dialogs
- Frontend: Users nav link in sidebar, routes in App.tsx
- Seed data: 55 users, POCs as standing, regular members as core/collaborator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:21:56 -07:00
parent 696fb39de5
commit cd0d6c3cff
18 changed files with 1678 additions and 59 deletions

View File

@@ -38,17 +38,20 @@ def create_app(config_class=Config):
from app.licenses import licenses_bp
from app.certs import certs_bp
from app.dashboard import dashboard_bp
from app.users import users_bp
app.register_blueprint(auth_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(licenses_bp)
app.register_blueprint(certs_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(users_bp)
# Import models so they are known to SQLAlchemy
from app.projects.models import Project # noqa: F401
from app.licenses.models import License # noqa: F401
from app.certs.models import Cert # noqa: F401
from app.users.models import User, ProjectUser # noqa: F401
from app.audit import AuditLog # noqa: F401
return app

View File

@@ -38,6 +38,17 @@ class AuditLog(db.Model):
from app.certs.models import Cert
entity = db.session.get(Cert, self.entity_id)
return entity.name if entity else f"Deleted (ID: {self.entity_id})"
elif self.entity_type == "user":
from app.users.models import User
entity = db.session.get(User, self.entity_id)
return entity.name if entity else f"Deleted (ID: {self.entity_id})"
elif self.entity_type == "project_user":
# Name is stored in details for membership changes
import json
if self.details:
details = json.loads(self.details)
return details.get("user_name", f"ID: {self.entity_id}")
return f"ID: {self.entity_id}"
except Exception:
pass
return f"Deleted (ID: {self.entity_id})"

View File

@@ -9,6 +9,7 @@ from app.projects.models import Project
from app.licenses.models import License
from app.certs.models import Cert
from app.audit import AuditLog
from app.users.models import User, ProjectUser
@dashboard_bp.route("/stats", methods=["GET"])
@@ -52,9 +53,12 @@ def stats():
Cert.not_valid_after < now_dt,
).count()
# Keycloak user counts (stubbed — will call Keycloak REST API later)
total_users = _get_total_users()
active_users = _get_active_users()
# Real user counts from the users table
total_users = User.query.count()
# "Active" = users who are core on at least one project
active_users = db.session.query(ProjectUser.user_id).filter(
ProjectUser.billing_type == "core"
).distinct().count()
return jsonify({
"projects_count": projects_count,
@@ -69,21 +73,6 @@ def stats():
}), 200
def _get_total_users():
"""Stub: total users from Keycloak.
TODO: Replace with GET {keycloak}/admin/realms/{realm}/users/count
"""
return 142
def _get_active_users():
"""Stub: active users from Keycloak.
TODO: Replace with Keycloak active sessions count via
GET {keycloak}/admin/realms/{realm}/client-session-stats
"""
return 37
@dashboard_bp.route("/expiring", methods=["GET"])
@login_required
def expiring():

View File

@@ -8,6 +8,7 @@ from app.audit import log_audit
from app.extensions import db
from app.projects import projects_bp
from app.projects.models import Project
from app.users.models import User, ProjectUser, BILLING_TYPES
@projects_bp.route("", methods=["GET"])
@@ -146,3 +147,156 @@ def update_project(key):
return jsonify(project.to_dict(include_counts=True)), 200
# ── Project-scoped user management ─────────────────────────────
@projects_bp.route("/<string:key>/users", methods=["GET"])
@login_required
def list_project_users(key):
project = Project.query.filter_by(key=key).first()
if not project:
return jsonify({"error": f"Project '{key}' not found"}), 404
memberships = ProjectUser.query.filter_by(project_id=project.id).all()
return jsonify({"users": [m.to_dict() for m in memberships]}), 200
@projects_bp.route("/<string:key>/users", methods=["POST"])
@login_required
def add_project_user(key):
project = Project.query.filter_by(key=key).first()
if not project:
return jsonify({"error": f"Project '{key}' not found"}), 404
data = request.get_json()
if not data:
return jsonify({"error": "Missing JSON body"}), 400
user_id = data.get("user_id")
if not user_id:
return jsonify({"error": "Field 'user_id' is required"}), 400
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404
existing = ProjectUser.query.filter_by(project_id=project.id, user_id=user_id).first()
if existing:
return jsonify({"error": "User is already a member of this project"}), 400
billing_type = data.get("billing_type", "core")
if billing_type not in BILLING_TYPES:
return jsonify({"error": f"billing_type must be one of: {', '.join(BILLING_TYPES)}"}), 400
membership = ProjectUser(
project_id=project.id,
user_id=user_id,
billing_type=billing_type,
)
db.session.add(membership)
db.session.commit()
log_audit("project_user", membership.id, "added", {
"user_id": user.id,
"user_name": user.name,
"project_key": project.key,
"project_name": project.name,
"billing_type": billing_type,
})
return jsonify(membership.to_dict()), 201
@projects_bp.route("/<string:key>/users/<int:user_id>", methods=["PUT"])
@login_required
def update_project_user(key, user_id):
project = Project.query.filter_by(key=key).first()
if not project:
return jsonify({"error": f"Project '{key}' not found"}), 404
membership = ProjectUser.query.filter_by(project_id=project.id, user_id=user_id).first()
if not membership:
return jsonify({"error": "User is not a member of this project"}), 404
data = request.get_json()
if not data:
return jsonify({"error": "Missing JSON body"}), 400
new_billing_type = data.get("billing_type")
if not new_billing_type:
return jsonify({"error": "Field 'billing_type' is required"}), 400
if new_billing_type not in BILLING_TYPES:
return jsonify({"error": f"billing_type must be one of: {', '.join(BILLING_TYPES)}"}), 400
old_billing_type = membership.billing_type
# When downgrading from core to collaborator, check coverage
if old_billing_type == "core" and new_billing_type == "collaborator":
if not data.get("confirmed"):
other_core = ProjectUser.query.filter(
ProjectUser.user_id == user_id,
ProjectUser.project_id != project.id,
ProjectUser.billing_type == "core",
).first()
if not other_core:
return jsonify({
"warning": "This person will not be covered by any other project",
"requires_confirmation": True,
}), 409
membership.billing_type = new_billing_type
membership.updated_at = datetime.now(timezone.utc)
db.session.commit()
log_audit("project_user", membership.id, "billing_changed", {
"user_id": user_id,
"user_name": membership.user.name,
"project_key": project.key,
"project_name": project.name,
"old_billing_type": old_billing_type,
"new_billing_type": new_billing_type,
})
return jsonify(membership.to_dict()), 200
@projects_bp.route("/<string:key>/users/<int:user_id>", methods=["DELETE"])
@login_required
def remove_project_user(key, user_id):
project = Project.query.filter_by(key=key).first()
if not project:
return jsonify({"error": f"Project '{key}' not found"}), 404
membership = ProjectUser.query.filter_by(project_id=project.id, user_id=user_id).first()
if not membership:
return jsonify({"error": "User is not a member of this project"}), 404
# Warn if removing a core user who isn't core on any other project
if membership.billing_type == "core":
confirmed = request.args.get("confirmed", "").lower() == "true"
if not confirmed:
other_core = ProjectUser.query.filter(
ProjectUser.user_id == user_id,
ProjectUser.project_id != project.id,
ProjectUser.billing_type == "core",
).first()
if not other_core:
return jsonify({
"warning": "This person will not be covered by any other project",
"requires_confirmation": True,
}), 409
user_name = membership.user.name
db.session.delete(membership)
db.session.commit()
log_audit("project_user", None, "removed", {
"user_id": user_id,
"user_name": user_name,
"project_key": project.key,
"project_name": project.name,
})
return jsonify({"message": f"User '{user_name}' removed from project"}), 200

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
from app.users import routes # noqa: E402, F401

View File

@@ -0,0 +1,78 @@
from datetime import datetime, timezone
from app.extensions import db
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
email = db.Column(db.String(200), nullable=False, unique=True)
keycloak_id = db.Column(db.String(200), nullable=True, unique=True)
created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# Relationships
project_memberships = db.relationship("ProjectUser", back_populates="user", lazy="dynamic")
def to_dict(self, include_projects=False):
d = {
"id": self.id,
"name": self.name,
"email": self.email,
"keycloak_id": self.keycloak_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_projects:
d["projects"] = [m.to_dict() for m in self.project_memberships]
return d
BILLING_TYPES = ("core", "collaborator", "standing")
class ProjectUser(db.Model):
__tablename__ = "project_users"
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
billing_type = db.Column(db.String(20), nullable=False, default="core")
created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(
db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
__table_args__ = (
db.UniqueConstraint("project_id", "user_id", name="uq_project_user"),
)
# Relationships
user = db.relationship("User", back_populates="project_memberships")
project = db.relationship("Project", backref=db.backref("user_memberships", lazy="dynamic"))
def to_dict(self):
return {
"id": self.id,
"project_id": self.project_id,
"project_key": self.project.key if self.project else None,
"project_name": self.project.name if self.project else None,
"user_id": self.user_id,
"user_name": self.user.name if self.user else None,
"user_email": self.user.email if self.user else None,
"billing_type": self.billing_type,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

168
backend/app/users/routes.py Normal file
View File

@@ -0,0 +1,168 @@
from datetime import datetime, timezone
from flask import request, jsonify
from app.auth.decorators import login_required
from app.audit import log_audit, AuditLog
from app.extensions import db
from app.users import users_bp
from app.users.models import User, ProjectUser
# ── User CRUD ──────────────────────────────────────────────────
@users_bp.route("", methods=["GET"])
@login_required
def list_users():
query = User.query
search = request.args.get("search", "").strip()
if search:
pattern = f"%{search}%"
query = query.filter(
db.or_(
User.name.ilike(pattern),
User.email.ilike(pattern),
)
)
sort_field = request.args.get("sort", "name")
order = request.args.get("order", "asc")
sortable = {
"name": User.name,
"email": User.email,
"created_at": User.created_at,
}
col = sortable.get(sort_field, User.name)
if order == "desc":
col = col.desc()
query = query.order_by(col)
users = query.all()
return jsonify({"users": [u.to_dict(include_projects=True) for u in users]}), 200
@users_bp.route("", methods=["POST"])
@login_required
def create_user():
data = request.get_json()
if not data:
return jsonify({"error": "Missing JSON body"}), 400
name = data.get("name", "").strip()
email = data.get("email", "").strip()
if not name or not email:
return jsonify({"error": "Fields 'name' and 'email' are required"}), 400
if User.query.filter_by(email=email).first():
return jsonify({"error": f"User with email '{email}' already exists"}), 400
user = User(
name=name,
email=email,
keycloak_id=data.get("keycloak_id"),
)
db.session.add(user)
db.session.commit()
log_audit("user", user.id, "created", {"name": user.name, "email": user.email})
return jsonify(user.to_dict(include_projects=True)), 201
@users_bp.route("/<int:user_id>", methods=["GET"])
@login_required
def get_user(user_id):
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify(user.to_dict(include_projects=True)), 200
@users_bp.route("/<int:user_id>", methods=["PUT"])
@login_required
def update_user(user_id):
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404
data = request.get_json()
if not data:
return jsonify({"error": "Missing JSON body"}), 400
changes = {}
for field in ("name", "email", "keycloak_id"):
if field in data:
old_val = getattr(user, field)
new_val = data[field]
setattr(user, field, new_val)
if old_val != new_val:
changes[field] = {"old": old_val, "new": new_val}
user.updated_at = datetime.now(timezone.utc)
db.session.commit()
log_audit("user", user.id, "updated", changes)
return jsonify(user.to_dict(include_projects=True)), 200
@users_bp.route("/<int:user_id>", methods=["DELETE"])
@login_required
def delete_user(user_id):
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404
user_name = user.name
# Remove all project memberships first
ProjectUser.query.filter_by(user_id=user_id).delete()
db.session.delete(user)
db.session.commit()
log_audit("user", user_id, "deleted", {"name": user_name})
return jsonify({"message": f"User '{user_name}' deleted"}), 200
# ── User audit history ─────────────────────────────────────────
@users_bp.route("/<int:user_id>/history", methods=["GET"])
@login_required
def user_history(user_id):
"""Return audit log entries relevant to this user."""
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404
# Get entries where this user is the subject (entity_type=user, entity_id=user_id)
# or where the user is mentioned in details (project membership changes)
direct_entries = AuditLog.query.filter(
AuditLog.entity_type == "user",
AuditLog.entity_id == user_id,
)
# Also get project_user entries that reference this user
membership_entries = AuditLog.query.filter(
AuditLog.entity_type == "project_user",
AuditLog.details.contains(f'"user_id": {user_id}'),
)
entries = direct_entries.union(membership_entries).order_by(AuditLog.created_at.desc()).limit(50).all()
return jsonify({"entries": [e.to_dict() for e in entries]}), 200
# ── Project membership endpoints ───────────────────────────────
@users_bp.route("/<int:user_id>/projects", methods=["GET"])
@login_required
def user_projects(user_id):
"""List all project memberships for a user."""
user = db.session.get(User, user_id)
if not user:
return jsonify({"error": "User not found"}), 404
memberships = ProjectUser.query.filter_by(user_id=user_id).all()
return jsonify({"memberships": [m.to_dict() for m in memberships]}), 200

View File

@@ -6,6 +6,7 @@ from app.extensions import db
from app.projects.models import Project
from app.licenses.models import License
from app.certs.models import Cert
from app.users.models import User, ProjectUser
PROJECTS = [
@@ -345,6 +346,182 @@ def _seed_certs(now, projects):
print(f"Seeded {len(CERTS)} certificates.")
USERS = [
# POCs (BFM, PM, Admin) — standing billing on their projects
{"name": "Priya Patel", "email": "priya.patel@acme.com"},
{"name": "Michael Chen", "email": "michael.chen@acme.com"},
{"name": "David Nguyen", "email": "david.nguyen@acme.com"},
{"name": "Laura Kim", "email": "laura.kim@atlas.io"},
{"name": "James Rodriguez", "email": "james.r@atlas.io"},
{"name": "Sarah Thompson", "email": "sarah.t@atlas.io"},
{"name": "Tom Bradley", "email": "tom.bradley@beacon.co"},
{"name": "Nina Vasquez", "email": "nina.v@beacon.co"},
{"name": "Chris Lee", "email": "chris.lee@beacon.co"},
{"name": "Angela Morris", "email": "amorris@citadel.net"},
{"name": "Raj Kapoor", "email": "raj.kapoor@citadel.net"},
{"name": "Emily Sato", "email": "emily.sato@citadel.net"},
{"name": "Frank Gomez", "email": "fgomez@delta.com"},
{"name": "Karen Wu", "email": "karen.wu@delta.com"},
{"name": "Marcus Johnson", "email": "marcus.j@delta.com"},
{"name": "Diana Ross", "email": "diana.ross@echo.edu"},
{"name": "Steve Park", "email": "steve.park@echo.edu"},
{"name": "Omar Hassan", "email": "omar.h@forge.dev"},
{"name": "Rachel Green", "email": "rachel.g@forge.dev"},
{"name": "Kevin Brown", "email": "kevin.b@forge.dev"},
{"name": "Helen Zhang", "email": "helen.z@glacier.io"},
{"name": "Brian Miller", "email": "brian.m@glacier.io"},
{"name": "Paul Davis", "email": "paul.d@harbor.com"},
{"name": "Michelle Tanaka", "email": "michelle.t@harbor.com"},
{"name": "Sandra Kowalski", "email": "sandra.k@iris.co"},
{"name": "Derek Olson", "email": "derek.o@iris.co"},
{"name": "Victor Lam", "email": "victor.l@jade.com"},
{"name": "Julia Fischer", "email": "julia.f@jade.com"},
{"name": "Natalie Brooks", "email": "natalie.b@keystone.sec"},
{"name": "Ryan Cooper", "email": "ryan.c@keystone.sec"},
{"name": "Patricia Novak", "email": "patricia.n@meteor.ops"},
{"name": "Andrew Kim", "email": "andrew.k@meteor.ops"},
{"name": "Henry Tran", "email": "henry.t@nova.app"},
{"name": "Rebecca Stone", "email": "rebecca.s@nova.app"},
{"name": "Jason Lim", "email": "jason.l@nova.app"},
# Regular team members — core/collaborator billing
{"name": "Aiden Walsh", "email": "aiden.w@acme.com"},
{"name": "Sofia Reyes", "email": "sofia.r@acme.com"},
{"name": "Liam O'Connor", "email": "liam.oc@acme.com"},
{"name": "Mia Johannsen", "email": "mia.j@atlas.io"},
{"name": "Ethan Blake", "email": "ethan.b@atlas.io"},
{"name": "Chloe Dubois", "email": "chloe.d@beacon.co"},
{"name": "Noah Pham", "email": "noah.p@beacon.co"},
{"name": "Ava Petrov", "email": "ava.p@citadel.net"},
{"name": "Lucas Ferreira", "email": "lucas.f@citadel.net"},
{"name": "Zara Okafor", "email": "zara.o@delta.com"},
{"name": "Isaac Yamamoto", "email": "isaac.y@echo.edu"},
{"name": "Ella Johansson", "email": "ella.j@echo.edu"},
{"name": "Oliver Katz", "email": "oliver.k@forge.dev"},
{"name": "Harper Singh", "email": "harper.s@glacier.io"},
{"name": "William Torres", "email": "william.t@harbor.com"},
{"name": "Grace Nakamura", "email": "grace.n@iris.co"},
{"name": "Benjamin Cho", "email": "benjamin.c@jade.com"},
{"name": "Lily Andersen", "email": "lily.a@keystone.sec"},
{"name": "Jack Morales", "email": "jack.m@meteor.ops"},
{"name": "Aria Bianchi", "email": "aria.b@nova.app"},
]
# Map user emails to project indices and billing types
# Each entry: (user_email, project_idx, billing_type)
# POCs are standing; regular team members are core or collaborator
PROJECT_USER_ASSIGNMENTS = [
# ACME (0) - POCs standing, 3 core, 1 collaborator
("priya.patel@acme.com", 0, "standing"), # BFM
("michael.chen@acme.com", 0, "standing"), # PM
("david.nguyen@acme.com", 0, "standing"), # Admin
("aiden.w@acme.com", 0, "core"),
("sofia.r@acme.com", 0, "core"),
("liam.oc@acme.com", 0, "core"),
("oliver.k@forge.dev", 0, "collaborator"),
# ATLAS (1) - POCs standing, 2 core, 1 collaborator
("laura.kim@atlas.io", 1, "standing"), # BFM
("james.r@atlas.io", 1, "standing"), # PM
("sarah.t@atlas.io", 1, "standing"), # Admin
("mia.j@atlas.io", 1, "core"),
("ethan.b@atlas.io", 1, "core"),
("sofia.r@acme.com", 1, "collaborator"),
# BEACN (2) - POCs standing, 2 core, 1 collaborator
("tom.bradley@beacon.co", 2, "standing"), # BFM
("nina.v@beacon.co", 2, "standing"), # PM
("chris.lee@beacon.co", 2, "standing"), # Admin
("chloe.d@beacon.co", 2, "core"),
("noah.p@beacon.co", 2, "core"),
("grace.n@iris.co", 2, "collaborator"),
# CITDL (3) - POCs standing, 2 core
("amorris@citadel.net", 3, "standing"), # BFM
("raj.kapoor@citadel.net", 3, "standing"), # PM
("emily.sato@citadel.net", 3, "standing"), # Admin
("ava.p@citadel.net", 3, "core"),
("lucas.f@citadel.net", 3, "core"),
# DELTA (4) - POCs standing, 1 core
("fgomez@delta.com", 4, "standing"), # BFM
("karen.wu@delta.com", 4, "standing"), # PM
("marcus.j@delta.com", 4, "standing"), # Admin
("zara.o@delta.com", 4, "core"),
# ECHO (5) - POCs standing, 2 core
("diana.ross@echo.edu", 5, "standing"), # BFM
("steve.park@echo.edu", 5, "standing"), # PM
("isaac.y@echo.edu", 5, "core"),
("ella.j@echo.edu", 5, "core"),
# FORGE (6) - POCs standing, 2 core, 1 collaborator
("omar.h@forge.dev", 6, "standing"), # BFM
("rachel.g@forge.dev", 6, "standing"), # PM
("kevin.b@forge.dev", 6, "standing"), # Admin
("oliver.k@forge.dev", 6, "core"),
("liam.oc@acme.com", 6, "collaborator"),
# GLACR (7) - POCs standing, 1 core
("helen.z@glacier.io", 7, "standing"), # BFM
("brian.m@glacier.io", 7, "standing"), # PM
("harper.s@glacier.io", 7, "core"),
# HARBR (8) - POCs standing, 1 core, 1 collaborator
("paul.d@harbor.com", 8, "standing"), # BFM
("michelle.t@harbor.com", 8, "standing"), # PM
("william.t@harbor.com", 8, "core"),
("lucas.f@citadel.net", 8, "collaborator"),
# IRIS (9) - POCs standing, 1 core
("sandra.k@iris.co", 9, "standing"), # BFM
("derek.o@iris.co", 9, "standing"), # PM
("grace.n@iris.co", 9, "core"),
# JADE (10) - POCs standing, 1 core
("victor.l@jade.com", 10, "standing"), # BFM
("julia.f@jade.com", 10, "standing"), # PM
("benjamin.c@jade.com", 10, "core"),
# KYSTO (11) - POCs standing, 1 core
("natalie.b@keystone.sec", 11, "standing"), # BFM
("ryan.c@keystone.sec", 11, "standing"), # PM
("lily.a@keystone.sec", 11, "core"),
# METOR (13) - POCs standing, 1 core
("patricia.n@meteor.ops", 13, "standing"), # BFM
("andrew.k@meteor.ops", 13, "standing"), # PM
("jack.m@meteor.ops", 13, "core"),
# NOVA (14) - POCs standing, 1 core, 1 collaborator
("henry.t@nova.app", 14, "standing"), # BFM
("rebecca.s@nova.app", 14, "standing"), # PM
("jason.l@nova.app", 14, "standing"), # Admin
("aria.b@nova.app", 14, "core"),
("chloe.d@beacon.co", 14, "collaborator"),
]
def _seed_users(now, projects):
if User.query.first() is not None:
return
user_map = {}
for i, u in enumerate(USERS):
user = User(
name=u["name"],
email=u["email"],
created_at=now - timedelta(days=200 - i * 3),
updated_at=now - timedelta(days=100 - i),
)
db.session.add(user)
user_map[u["email"]] = user
db.session.flush()
for email, proj_idx, billing_type in PROJECT_USER_ASSIGNMENTS:
user = user_map.get(email)
project = projects[proj_idx] if proj_idx < len(projects) and projects else None
if user and project:
membership = ProjectUser(
project_id=project.id,
user_id=user.id,
billing_type=billing_type,
created_at=now - timedelta(days=150),
updated_at=now - timedelta(days=50),
)
db.session.add(membership)
db.session.flush()
print(f"Seeded {len(USERS)} users with {len(PROJECT_USER_ASSIGNMENTS)} project assignments.")
def seed():
"""Insert sample data if tables are empty."""
now = datetime.now(timezone.utc)
@@ -352,5 +529,6 @@ def seed():
projects = _seed_projects(now)
_seed_licenses(now, projects)
_seed_certs(now, projects)
_seed_users(now, projects)
db.session.commit()

View File

@@ -11,6 +11,8 @@ import ProjectsListPage from "@/pages/projects-list";
import ProjectDetailPage from "@/pages/project-detail";
import LicensesListPage from "@/pages/licenses-list";
import CertsListPage from "@/pages/certs-list";
import UsersListPage from "@/pages/users-list";
import UserDetailPage from "@/pages/user-detail";
const queryClient = new QueryClient({
defaultOptions: {
@@ -45,6 +47,8 @@ export default function App() {
/>
<Route path="/licenses" element={<LicensesListPage />} />
<Route path="/certs" element={<CertsListPage />} />
<Route path="/users" element={<UsersListPage />} />
<Route path="/users/:id" element={<UserDetailPage />} />
</Route>
<Route
path="*"

View File

@@ -5,6 +5,7 @@ import {
FolderKanban,
KeyRound,
ShieldCheck,
Users,
LogOut,
PanelLeftClose,
PanelLeftOpen,
@@ -27,6 +28,7 @@ const navItems = [
{ to: "/projects", icon: FolderKanban, label: "Projects" },
{ to: "/licenses", icon: KeyRound, label: "Licenses" },
{ to: "/certs", icon: ShieldCheck, label: "Certificates" },
{ to: "/users", icon: Users, label: "Users" },
];
interface SidebarProps {

View File

@@ -7,7 +7,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, AlertTriangle } from "lucide-react";
import { Loader2, AlertTriangle, CirclePlus } from "lucide-react";
interface ConfirmDialogProps {
open: boolean;
@@ -18,9 +18,29 @@ interface ConfirmDialogProps {
cancelLabel?: string;
onConfirm: () => void;
isLoading?: boolean;
variant?: "default" | "destructive";
variant?: "default" | "destructive" | "success";
children?: React.ReactNode;
}
const VARIANT_ICON: Record<string, React.ReactNode> = {
destructive: (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-950">
<AlertTriangle className="size-5 text-red-600 dark:text-red-400" />
</div>
),
success: (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950">
<CirclePlus className="size-5 text-emerald-600 dark:text-emerald-400" />
</div>
),
};
const VARIANT_BUTTON: Record<string, "destructive" | "default"> = {
destructive: "destructive",
default: "default",
success: "default",
};
export function ConfirmDialog({
open,
onOpenChange,
@@ -31,23 +51,21 @@ export function ConfirmDialog({
onConfirm,
isLoading = false,
variant = "destructive",
children,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3">
{variant === "destructive" && (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-950">
<AlertTriangle className="size-5 text-red-600 dark:text-red-400" />
</div>
)}
{VARIANT_ICON[variant]}
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</div>
</div>
</DialogHeader>
{children}
<DialogFooter>
<Button
variant="outline"
@@ -57,7 +75,7 @@ export function ConfirmDialog({
{cancelLabel}
</Button>
<Button
variant={variant === "destructive" ? "destructive" : "default"}
variant={VARIANT_BUTTON[variant]}
onClick={onConfirm}
disabled={isLoading}
>

View File

@@ -0,0 +1,77 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface UserFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { name: string; email: string }) => void;
isLoading?: boolean;
}
export function UserForm({ open, onOpenChange, onSubmit, isLoading }: UserFormProps) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim() || !email.trim()) return;
onSubmit({ name: name.trim(), email: email.trim() });
setName("");
setEmail("");
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>New User</DialogTitle>
<DialogDescription>Add a new user to the system.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-1.5">
<label htmlFor="user-name" className="text-sm font-medium">
Name
</label>
<Input
id="user-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Full name"
required
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="user-email" className="text-sm font-medium">
Email
</label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating..." : "Create User"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,98 @@
import type { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Trash2 } from "lucide-react";
import type { User } from "@/hooks/use-users";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const BILLING_STYLES: Record<string, string> = {
core: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400",
collaborator: "bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
standing: "bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400",
};
interface UsersColumnsOptions {
onDelete: (user: User) => void;
}
export function getUsersColumns(
opts: UsersColumnsOptions
): ColumnDef<User, unknown>[] {
return [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => (
<span className="font-medium">{row.getValue("name")}</span>
),
},
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Email" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">{row.getValue("email")}</span>
),
},
{
id: "projects",
header: "Projects",
cell: ({ row }) => {
const projects = row.original.projects;
if (projects.length === 0) {
return <span className="text-muted-foreground">--</span>;
}
return (
<div className="flex flex-wrap gap-1.5">
{projects.map((m) => (
<span
key={m.id}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${BILLING_STYLES[m.billing_type] ?? BILLING_STYLES.standing}`}
>
{m.project_key}
</span>
))}
</div>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const user = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" />
}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">Actions</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.stopPropagation();
opts.onDelete(user);
}}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}

View File

@@ -0,0 +1,68 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
import type { ProjectMembership } from "./use-users";
export function useProjectUsers(projectKey: string | undefined) {
return useQuery({
queryKey: ["projects", projectKey, "users"],
queryFn: async () => {
const res = await api.get(`/projects/${projectKey}/users`);
return res.data.users as ProjectMembership[];
},
enabled: !!projectKey,
});
}
export function useAddProjectUser(projectKey: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { user_id: number; billing_type?: string }) => {
const res = await api.post(`/projects/${projectKey}/users`, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects", projectKey, "users"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useUpdateProjectUserBilling(projectKey: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { user_id: number; billing_type: string; confirmed?: boolean }) => {
const res = await api.put(`/projects/${projectKey}/users/${data.user_id}`, {
billing_type: data.billing_type,
confirmed: data.confirmed,
});
return res.data as ProjectMembership;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects", projectKey, "users"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
onError: () => {
// 409 errors are handled by the caller for the warning flow
},
});
}
export function useRemoveProjectUser(projectKey: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { userId: number; confirmed?: boolean }) => {
const params = data.confirmed ? { confirmed: "true" } : undefined;
await api.delete(`/projects/${projectKey}/users/${data.userId}`, { params });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects", projectKey, "users"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}

View File

@@ -0,0 +1,124 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "@/lib/api";
export interface ProjectMembership {
id: number;
project_id: number;
project_key: string;
project_name: string;
user_id: number;
user_name: string;
user_email: string;
billing_type: "core" | "collaborator" | "standing";
created_at: string;
updated_at: string;
}
export interface User {
id: number;
name: string;
email: string;
keycloak_id: string | null;
projects: ProjectMembership[];
created_at: string;
updated_at: string;
}
interface AuditEntry {
id: number;
entity_type: string;
entity_id: number | null;
entity_name: string | null;
action: string;
user: string;
details: Record<string, unknown> | null;
created_at: string;
}
export function useUsers(params?: { search?: string }) {
return useQuery({
queryKey: ["users", params],
queryFn: async () => {
const res = await api.get("/users", { params });
return res.data.users as User[];
},
});
}
export function useUser(id: number | undefined) {
return useQuery({
queryKey: ["users", id],
queryFn: async () => {
const res = await api.get(`/users/${id}`);
return res.data as User;
},
enabled: !!id,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; email: string }) => {
const res = await api.post("/users", data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
export function useUpdateUser(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<User>) => {
const res = await api.put(`/users/${id}`, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
export function useDeleteUser(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
await api.delete(`/users/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
export function useAddUserToProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { user_id: number; project_key: string; billing_type: string }) => {
const res = await api.post(`/projects/${data.project_key}/users`, {
user_id: data.user_id,
billing_type: data.billing_type,
});
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useUserHistory(id: number | undefined) {
return useQuery({
queryKey: ["users", id, "history"],
queryFn: async () => {
const res = await api.get(`/users/${id}/history`);
return res.data.entries as AuditEntry[];
},
enabled: !!id,
});
}

View File

@@ -23,6 +23,13 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Breadcrumb,
BreadcrumbItem,
@@ -32,32 +39,59 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { StatusBadge } from "@/components/shared/status-badge";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { ProjectForm } from "@/components/projects/project-form";
import { useProject, useUpdateProject } from "@/hooks/use-projects";
import { useUsers } from "@/hooks/use-users";
import {
useProjectUsers,
useAddProjectUser,
useUpdateProjectUserBilling,
useRemoveProjectUser,
} from "@/hooks/use-project-users";
import type { ProjectMembership } from "@/hooks/use-users";
interface MockUser {
id: number;
name: string;
email: string;
role: string;
}
const BILLING_STYLES: Record<string, string> = {
core: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400",
collaborator: "bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
standing: "bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400",
};
const INITIAL_MOCK_USERS: MockUser[] = [
{ id: 1, name: "Alice Johnson", email: "alice@example.com", role: "Developer" },
{ id: 2, name: "Bob Smith", email: "bob@example.com", role: "Admin" },
{ id: 3, name: "Carol Davis", email: "carol@example.com", role: "Viewer" },
];
const BILLING_LABELS: Record<string, string> = {
core: "Core",
collaborator: "Collaborator",
standing: "Standing",
};
export default function ProjectDetailPage() {
const { key } = useParams<{ key: string }>();
const navigate = useNavigate();
const { data, isLoading } = useProject(key);
const updateProject = useUpdateProject(key ?? "");
const { data: projectUsers, isLoading: usersLoading } = useProjectUsers(key);
const { data: allUsers } = useUsers();
const addProjectUser = useAddProjectUser(key ?? "");
const updateBilling = useUpdateProjectUserBilling(key ?? "");
const removeProjectUser = useRemoveProjectUser(key ?? "");
const [editOpen, setEditOpen] = useState(false);
const [mockUsers, setMockUsers] = useState<MockUser[]>(INITIAL_MOCK_USERS);
const [addUserOpen, setAddUserOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedBillingType, setSelectedBillingType] = useState("core");
const [calcUsers, setCalcUsers] = useState("");
const [calcRate, setCalcRate] = useState("");
const [copiedEmail, setCopiedEmail] = useState<string | null>(null);
const [billingWarning, setBillingWarning] = useState<{
userId: number;
userName: string;
newType: string;
message: string;
} | null>(null);
const [removeWarning, setRemoveWarning] = useState<{
userId: number;
userName: string;
message: string;
} | null>(null);
if (isLoading) {
return (
@@ -87,9 +121,17 @@ export default function ProjectDetailPage() {
}
const project = data.project;
const coreCount = projectUsers?.filter((u) => u.billing_type === "core").length ?? 0;
const collaboratorCount = projectUsers?.filter((u) => u.billing_type === "collaborator").length ?? 0;
const effectiveRate = calcRate !== "" ? (Number(calcRate) || 0) : (project.rate ?? 0);
const calcUsersNum = Number(calcUsers) || 0;
const estimatedCost = calcUsersNum * effectiveRate;
const realEstCost = coreCount * (project.rate ?? 0);
// Users not already in this project
const availableUsers = allUsers?.filter(
(u) => !projectUsers?.some((pu) => pu.user_id === u.id)
) ?? [];
function handleUpdate(formData: Record<string, unknown>) {
updateProject.mutate(formData, {
@@ -101,16 +143,108 @@ export default function ProjectDetailPage() {
});
}
function removeUser(id: number) {
setMockUsers((prev) => prev.filter((u) => u.id !== id));
function handleAddUser() {
if (!selectedUserId) return;
addProjectUser.mutate(
{ user_id: Number(selectedUserId), billing_type: selectedBillingType },
{
onSuccess: () => {
toast.success("User added to project");
setAddUserOpen(false);
setSelectedUserId("");
setSelectedBillingType("core");
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || "Failed to add user";
toast.error(msg);
},
}
);
}
function addUser() {
const id = Math.max(0, ...mockUsers.map((u) => u.id)) + 1;
setMockUsers((prev) => [
...prev,
{ id, name: "New User", email: "user@example.com", role: "Viewer" },
]);
function handleBillingChange(membership: ProjectMembership, newType: string) {
if (newType === membership.billing_type) return;
updateBilling.mutate(
{ user_id: membership.user_id, billing_type: newType },
{
onSuccess: () => toast.success("Billing type updated"),
onError: (err: unknown) => {
const response = (err as { response?: { status?: number; data?: { warning?: string; requires_confirmation?: boolean } } })?.response;
if (response?.status === 409 && response.data?.requires_confirmation) {
setBillingWarning({
userId: membership.user_id,
userName: membership.user_name,
newType,
message: response.data.warning ?? "This action requires confirmation",
});
} else {
toast.error("Failed to update billing type");
}
},
}
);
}
function confirmBillingChange() {
if (!billingWarning) return;
updateBilling.mutate(
{
user_id: billingWarning.userId,
billing_type: billingWarning.newType,
confirmed: true,
},
{
onSuccess: () => {
toast.success("Billing type updated");
setBillingWarning(null);
},
onError: () => {
toast.error("Failed to update billing type");
setBillingWarning(null);
},
}
);
}
function handleRemoveUser(membership: ProjectMembership) {
removeProjectUser.mutate(
{ userId: membership.user_id },
{
onSuccess: () => toast.success("User removed from project"),
onError: (err: unknown) => {
const response = (err as { response?: { status?: number; data?: { warning?: string; requires_confirmation?: boolean } } })?.response;
if (response?.status === 409 && response.data?.requires_confirmation) {
setRemoveWarning({
userId: membership.user_id,
userName: membership.user_name,
message: response.data.warning ?? "This action requires confirmation",
});
} else {
toast.error("Failed to remove user");
}
},
}
);
}
function confirmRemoveUser() {
if (!removeWarning) return;
removeProjectUser.mutate(
{ userId: removeWarning.userId, confirmed: true },
{
onSuccess: () => {
toast.success("User removed from project");
setRemoveWarning(null);
},
onError: () => {
toast.error("Failed to remove user");
setRemoveWarning(null);
},
}
);
}
return (
@@ -167,7 +301,10 @@ export default function ProjectDetailPage() {
<Users className="size-3.5" />
Users
</div>
<p className="mt-1 text-lg font-semibold">0</p>
<p className="mt-1 text-lg font-semibold">{coreCount}</p>
{collaboratorCount > 0 && (
<p className="text-[10px] text-muted-foreground">+{collaboratorCount} collab</p>
)}
</div>
<div className="rounded-lg border bg-muted/30 p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@@ -182,7 +319,9 @@ export default function ProjectDetailPage() {
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
Est. Cost
</div>
<p className="mt-1 text-lg font-semibold">$0</p>
<p className="mt-1 text-lg font-semibold">
${realEstCost.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</p>
<p className="text-[10px] text-muted-foreground">/monthly</p>
</div>
</div>
@@ -235,12 +374,11 @@ export default function ProjectDetailPage() {
</div>
</section>
{/* Users */}
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Users Associated with Project</h2>
<Button size="sm" variant="outline" onClick={addUser}>
<Button size="sm" variant="outline" onClick={() => setAddUserOpen(true)}>
<UserPlus className="size-4 mr-2" />
Add User
</Button>
@@ -252,28 +390,61 @@ export default function ProjectDetailPage() {
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Billing Type</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockUsers.length === 0 ? (
{usersLoading ? (
<TableRow>
<TableCell colSpan={4} className="py-8">
<Skeleton className="h-5 w-full" />
</TableCell>
</TableRow>
) : !projectUsers || projectUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
No users assigned to this project.
</TableCell>
</TableRow>
) : (
mockUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.role}</TableCell>
projectUsers.map((membership) => (
<TableRow key={membership.id}>
<TableCell>
<Link
to={`/users/${membership.user_id}`}
className="font-medium hover:underline"
>
{membership.user_name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{membership.user_email}
</TableCell>
<TableCell>
<Select
value={membership.billing_type}
onValueChange={(val) => val && handleBillingChange(membership, val)}
>
<SelectTrigger className="w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(BILLING_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${BILLING_STYLES[value]}`}>
{label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => removeUser(user.id)}
onClick={() => handleRemoveUser(membership)}
>
<Trash2 className="size-3.5" />
</Button>
@@ -390,7 +561,7 @@ export default function ProjectDetailPage() {
</Card>
</section>
{/* Edit Dialog */}
{/* Edit Project Dialog */}
<ProjectForm
open={editOpen}
onOpenChange={setEditOpen}
@@ -399,6 +570,75 @@ export default function ProjectDetailPage() {
isLoading={updateProject.isPending}
mode="edit"
/>
{/* Add User Dialog */}
<ConfirmDialog
open={addUserOpen}
onOpenChange={setAddUserOpen}
title="Add User to Project"
description="Select a user and their billing type."
confirmLabel="Add"
onConfirm={handleAddUser}
isLoading={addProjectUser.isPending}
variant="success"
>
<div className="space-y-4 py-2">
<div className="grid gap-1.5">
<label className="text-sm font-medium">User</label>
<Select value={selectedUserId} onValueChange={(val) => setSelectedUserId(val ?? "")}>
<SelectTrigger>
<SelectValue placeholder="Select a user..." />
</SelectTrigger>
<SelectContent>
{availableUsers.map((u) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium">Billing Type</label>
<Select value={selectedBillingType} onValueChange={(val) => setSelectedBillingType(val ?? "core")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="core">Core</SelectItem>
<SelectItem value="collaborator">Collaborator</SelectItem>
<SelectItem value="standing">Standing</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</ConfirmDialog>
{/* Billing Change Warning Dialog */}
<ConfirmDialog
open={!!billingWarning}
onOpenChange={(open) => {
if (!open) setBillingWarning(null);
}}
title="Warning"
description={billingWarning?.message ?? ""}
confirmLabel="Continue"
onConfirm={confirmBillingChange}
isLoading={updateBilling.isPending}
/>
{/* Remove User Warning Dialog */}
<ConfirmDialog
open={!!removeWarning}
onOpenChange={(open) => {
if (!open) setRemoveWarning(null);
}}
title="Warning"
description={removeWarning?.message ?? ""}
confirmLabel="Remove Anyway"
onConfirm={confirmRemoveUser}
isLoading={removeProjectUser.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,292 @@
import { useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { format, parseISO } from "date-fns";
import { toast } from "sonner";
import { UserPlus } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useUser, useUserHistory, useAddUserToProject } from "@/hooks/use-users";
import { useProjects } from "@/hooks/use-projects";
const BILLING_LABELS: Record<string, { label: string; className: string }> = {
core: {
label: "Core",
className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400",
},
collaborator: {
label: "Collaborator",
className: "bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400",
},
standing: {
label: "Standing",
className: "bg-gray-100 text-gray-600 dark:bg-gray-900/30 dark:text-gray-400",
},
};
function BillingBadge({ type }: { type: string }) {
const style = BILLING_LABELS[type] ?? BILLING_LABELS.standing;
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${style.className}`}>
{style.label}
</span>
);
}
function formatHistoryEntry(entry: {
action: string;
details: Record<string, unknown> | null;
created_at: string;
}) {
const date = format(parseISO(entry.created_at), "MM/dd/yyyy");
const d = entry.details ?? {};
switch (entry.action) {
case "created":
return `${date} - User account created`;
case "updated":
return `${date} - Profile updated`;
case "added":
return `${date} - Added to ${d.project_name ?? "a project"} as ${d.billing_type ?? "member"}`;
case "removed":
return `${date} - Removed from ${d.project_name ?? "a project"}`;
case "billing_changed":
return `${date} - Changed from ${d.old_billing_type} to ${d.new_billing_type} on ${d.project_name ?? "a project"}`;
default:
return `${date} - ${entry.action}`;
}
}
export default function UserDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const userId = id ? Number(id) : undefined;
const { data: user, isLoading } = useUser(userId);
const { data: history, isLoading: historyLoading } = useUserHistory(userId);
const { data: allProjects } = useProjects();
const addToProject = useAddUserToProject();
const [addProjectOpen, setAddProjectOpen] = useState(false);
const [selectedProjectKey, setSelectedProjectKey] = useState("");
const [selectedBillingType, setSelectedBillingType] = useState("core");
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-48" />
<Skeleton className="h-48" />
</div>
);
}
if (!user) {
return (
<div className="py-16 text-center">
<p className="text-muted-foreground">User not found.</p>
<Button variant="outline" onClick={() => navigate("/users")} className="mt-4">
Back to Users
</Button>
</div>
);
}
// Projects not already assigned to this user
const availableProjects = allProjects?.filter(
(p) => !user.projects.some((m) => m.project_key === p.key)
) ?? [];
function handleAddToProject() {
if (!selectedProjectKey || !userId) return;
addToProject.mutate(
{ user_id: userId, project_key: selectedProjectKey, billing_type: selectedBillingType },
{
onSuccess: () => {
toast.success("Added to project");
setAddProjectOpen(false);
setSelectedProjectKey("");
setSelectedBillingType("core");
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || "Failed to add to project";
toast.error(msg);
},
}
);
}
return (
<div className="space-y-8">
<div>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink render={<Link to="/users" />}>Users</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{user.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="mt-3">
<h1 className="text-2xl font-semibold tracking-tight">{user.name}</h1>
<p className="text-sm text-muted-foreground">{user.email}</p>
<div className="mt-1 text-xs text-muted-foreground">
Member since {user.created_at ? format(parseISO(user.created_at), "MMM d, yyyy") : "—"}
</div>
</div>
</div>
{/* Project Memberships */}
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Projects</h2>
<Button size="sm" variant="outline" onClick={() => setAddProjectOpen(true)}>
<UserPlus className="size-4 mr-2" />
Add to Project
</Button>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Key</TableHead>
<TableHead>Billing Type</TableHead>
<TableHead>Since</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{user.projects.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
Not assigned to any projects.
</TableCell>
</TableRow>
) : (
user.projects.map((m) => (
<TableRow
key={m.id}
className="cursor-pointer"
onClick={() => navigate(`/projects/${m.project_key}`)}
>
<TableCell className="font-medium">{m.project_name}</TableCell>
<TableCell>
<span className="font-mono text-sm">{m.project_key}</span>
</TableCell>
<TableCell>
<BillingBadge type={m.billing_type} />
</TableCell>
<TableCell className="text-muted-foreground">
{m.created_at ? format(parseISO(m.created_at), "MMM d, yyyy") : "—"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</section>
{/* History */}
<section>
<h2 className="mb-3 text-lg font-semibold">History</h2>
<Card>
<CardContent>
{historyLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
) : !history || history.length === 0 ? (
<p className="text-sm text-muted-foreground">No history recorded yet.</p>
) : (
<ul className="space-y-2">
{history.map((entry) => (
<li key={entry.id} className="text-sm text-muted-foreground">
{formatHistoryEntry(entry)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</section>
{/* Add to Project Dialog */}
<ConfirmDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
title="Add to Project"
description="Select a project and billing type for this user."
confirmLabel="Add"
onConfirm={handleAddToProject}
isLoading={addToProject.isPending}
variant="success"
>
<div className="space-y-4 py-2">
<div className="grid gap-1.5">
<label className="text-sm font-medium">Project</label>
<Select value={selectedProjectKey} onValueChange={(val) => setSelectedProjectKey(val ?? "")}>
<SelectTrigger>
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{availableProjects.map((p) => (
<SelectItem key={p.key} value={p.key}>
{p.name} ({p.key})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium">Billing Type</label>
<Select value={selectedBillingType} onValueChange={(val) => setSelectedBillingType(val ?? "core")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="core">Core</SelectItem>
<SelectItem value="collaborator">Collaborator</SelectItem>
<SelectItem value="standing">Standing</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</ConfirmDialog>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/shared/data-table";
import { getUsersColumns } from "@/components/users/users-columns";
import { UserForm } from "@/components/users/user-form";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import {
useUsers,
useCreateUser,
useDeleteUser,
type User,
} from "@/hooks/use-users";
export default function UsersListPage() {
const { data: users, isLoading } = useUsers();
const createUser = useCreateUser();
const [showCreateForm, setShowCreateForm] = useState(false);
const [deletingUser, setDeletingUser] = useState<User | null>(null);
const deleteUser = useDeleteUser(deletingUser?.id ?? 0);
const navigate = useNavigate();
const columns = useMemo(
() =>
getUsersColumns({
onDelete: (user) => setDeletingUser(user),
}),
[]
);
function handleCreate(data: { name: string; email: string }) {
createUser.mutate(data, {
onSuccess: () => {
toast.success("User created successfully");
setShowCreateForm(false);
},
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || "Failed to create user";
toast.error(msg);
},
});
}
function handleDelete() {
deleteUser.mutate(undefined, {
onSuccess: () => {
toast.success("User deleted");
setDeletingUser(null);
},
onError: () => toast.error("Failed to delete user"),
});
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Users</h1>
<p className="text-sm text-muted-foreground">
Manage users and their project memberships.
</p>
</div>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="size-4" />
New User
</Button>
</div>
<DataTable
columns={columns}
data={users ?? []}
isLoading={isLoading}
searchKey="name"
searchPlaceholder="Search users..."
onRowClick={(user) => navigate(`/users/${user.id}`)}
emptyTitle="No users yet"
emptyDescription="Create your first user to start managing project memberships."
emptyAction={{
label: "New User",
onClick: () => setShowCreateForm(true),
}}
/>
<UserForm
open={showCreateForm}
onOpenChange={setShowCreateForm}
onSubmit={handleCreate}
isLoading={createUser.isPending}
/>
<ConfirmDialog
open={!!deletingUser}
onOpenChange={(open) => {
if (!open) setDeletingUser(null);
}}
title="Delete User"
description={`Are you sure you want to delete "${deletingUser?.name}"? This will remove them from all projects. This action cannot be undone.`}
confirmLabel="Delete"
onConfirm={handleDelete}
isLoading={deleteUser.isPending}
/>
</div>
);
}