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:
@@ -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
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
5
backend/app/users/__init__.py
Normal file
5
backend/app/users/__init__.py
Normal 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
|
||||
78
backend/app/users/models.py
Normal file
78
backend/app/users/models.py
Normal 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
168
backend/app/users/routes.py
Normal 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
|
||||
178
backend/seed.py
178
backend/seed.py
@@ -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()
|
||||
|
||||
@@ -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="*"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
77
frontend/src/components/users/user-form.tsx
Normal file
77
frontend/src/components/users/user-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/users/users-columns.tsx
Normal file
98
frontend/src/components/users/users-columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
68
frontend/src/hooks/use-project-users.ts
Normal file
68
frontend/src/hooks/use-project-users.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
124
frontend/src/hooks/use-users.ts
Normal file
124
frontend/src/hooks/use-users.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
292
frontend/src/pages/user-detail.tsx
Normal file
292
frontend/src/pages/user-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/users-list.tsx
Normal file
110
frontend/src/pages/users-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user