feat: implement workspace management and member invitations
- Added tenant API for managing workspaces, including listing, creating, and inviting members. - Introduced new models for organizations, workspaces, and workspace memberships in the backend. - Created frontend components for workspace administration, allowing users to manage workspaces and invite members. - Updated document listing to support workspace-specific filtering. - Implemented account settings page for user profile management and password changes. - Added end-to-end tests for workspace administration and dashboard functionalities.
This commit is contained in:
parent
809235e962
commit
bf5cd44dc1
20 changed files with 928 additions and 27 deletions
159
backend/app/services/tenant_service.py
Normal file
159
backend/app/services/tenant_service.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import re
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tenant import Organization, TenantAuditEvent, Workspace, WorkspaceInvitation, WorkspaceMembership
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")[:170] or "workspace"
|
||||
|
||||
|
||||
def get_membership(db: Session, workspace_id: int, user_id: int) -> WorkspaceMembership | None:
|
||||
return db.query(WorkspaceMembership).filter_by(workspace_id=workspace_id, user_id=user_id).first()
|
||||
|
||||
|
||||
def can_manage(role: str | None) -> bool:
|
||||
return role in {"owner", "admin"}
|
||||
|
||||
|
||||
def audit(db: Session, organization_id: int, workspace_id: int | None, actor_id: int, action: str, target: str) -> None:
|
||||
db.add(TenantAuditEvent(
|
||||
organization_id=organization_id,
|
||||
workspace_id=workspace_id,
|
||||
actor_id=actor_id,
|
||||
action=action,
|
||||
target=target,
|
||||
))
|
||||
|
||||
|
||||
def create_workspace(db: Session, owner: User, data) -> Workspace:
|
||||
org_slug = slugify(data.organization_name)
|
||||
if db.query(Organization).filter_by(slug=org_slug).first():
|
||||
org_slug = f"{org_slug}-{secrets.token_hex(3)}"
|
||||
organization = Organization(name=data.organization_name.strip(), slug=org_slug, owner_id=owner.id)
|
||||
db.add(organization)
|
||||
db.flush()
|
||||
workspace_slug = slugify(data.workspace_name)
|
||||
workspace = Workspace(
|
||||
organization_id=organization.id,
|
||||
name=data.workspace_name.strip(),
|
||||
slug=workspace_slug,
|
||||
)
|
||||
db.add(workspace)
|
||||
db.flush()
|
||||
db.add(WorkspaceMembership(workspace_id=workspace.id, user_id=owner.id, role="owner"))
|
||||
audit(db, organization.id, workspace.id, owner.id, "workspace.created", workspace.name)
|
||||
db.commit()
|
||||
db.refresh(workspace)
|
||||
return workspace
|
||||
|
||||
|
||||
def list_workspaces(db: Session, user_id: int):
|
||||
return (
|
||||
db.query(Workspace, Organization, WorkspaceMembership.role)
|
||||
.join(Organization, Organization.id == Workspace.organization_id)
|
||||
.join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id)
|
||||
.filter(WorkspaceMembership.user_id == user_id)
|
||||
.order_by(Workspace.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_workspace(db: Session, workspace_id: int, user_id: int):
|
||||
return (
|
||||
db.query(Workspace, Organization, WorkspaceMembership.role)
|
||||
.join(Organization, Organization.id == Workspace.organization_id)
|
||||
.join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id)
|
||||
.filter(Workspace.id == workspace_id, WorkspaceMembership.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def list_members(db: Session, workspace_id: int):
|
||||
return (
|
||||
db.query(User, WorkspaceMembership)
|
||||
.join(WorkspaceMembership, WorkspaceMembership.user_id == User.id)
|
||||
.filter(WorkspaceMembership.workspace_id == workspace_id)
|
||||
.order_by(User.full_name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def invite_member(db: Session, workspace_id: int, inviter_id: int, email: str, role: str):
|
||||
token = secrets.token_urlsafe(32)
|
||||
invitation = WorkspaceInvitation(
|
||||
workspace_id=workspace_id,
|
||||
inviter_id=inviter_id,
|
||||
email=email.lower(),
|
||||
role=role,
|
||||
token=token,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=7),
|
||||
)
|
||||
db.add(invitation)
|
||||
workspace = db.query(Workspace).filter_by(id=workspace_id).first()
|
||||
if workspace:
|
||||
audit(db, workspace.organization_id, workspace_id, inviter_id, "invitation.created", email.lower())
|
||||
db.commit()
|
||||
db.refresh(invitation)
|
||||
return invitation
|
||||
|
||||
|
||||
def revoke_invitation(db: Session, workspace_id: int, invitation_id: int, actor_id: int) -> bool:
|
||||
invitation = db.query(WorkspaceInvitation).filter_by(id=invitation_id, workspace_id=workspace_id, accepted=False).first()
|
||||
if invitation is None:
|
||||
return False
|
||||
invitation.expires_at = datetime.now(UTC)
|
||||
workspace = db.query(Workspace).filter_by(id=workspace_id).first()
|
||||
if workspace:
|
||||
audit(db, workspace.organization_id, workspace_id, actor_id, "invitation.revoked", invitation.email)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def transfer_ownership(db: Session, workspace_id: int, current_owner_id: int, next_owner_id: int) -> bool:
|
||||
current = get_membership(db, workspace_id, current_owner_id)
|
||||
target = get_membership(db, workspace_id, next_owner_id)
|
||||
workspace = db.query(Workspace).filter_by(id=workspace_id).first()
|
||||
if not current or current.role != "owner" or not target or not workspace:
|
||||
return False
|
||||
current.role = "admin"
|
||||
target.role = "owner"
|
||||
organization = db.query(Organization).filter_by(id=workspace.organization_id).first()
|
||||
if organization and organization.owner_id == current_owner_id:
|
||||
organization.owner_id = next_owner_id
|
||||
audit(db, workspace.organization_id, workspace_id, current_owner_id, "workspace.ownership_transferred", str(next_owner_id))
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def list_audit_events(db: Session, workspace_id: int, limit: int = 100):
|
||||
return db.query(TenantAuditEvent).filter_by(workspace_id=workspace_id).order_by(TenantAuditEvent.created_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
def accept_invitation(db: Session, token: str, user: User) -> WorkspaceMembership | None:
|
||||
invitation = db.query(WorkspaceInvitation).filter_by(token=token, accepted=False).first()
|
||||
if invitation is None or invitation.expires_at < datetime.now(UTC) or invitation.email != user.email.lower():
|
||||
return None
|
||||
membership = get_membership(db, invitation.workspace_id, user.id)
|
||||
if membership is None:
|
||||
membership = WorkspaceMembership(workspace_id=invitation.workspace_id, user_id=user.id, role=invitation.role)
|
||||
db.add(membership)
|
||||
invitation.accepted = True
|
||||
db.commit()
|
||||
db.refresh(membership)
|
||||
return membership
|
||||
|
||||
|
||||
def update_member_role(db: Session, workspace_id: int, target_id: int, role: str) -> WorkspaceMembership | None:
|
||||
membership = get_membership(db, workspace_id, target_id)
|
||||
if membership is None or membership.role == "owner":
|
||||
return None
|
||||
membership.role = role
|
||||
db.commit()
|
||||
db.refresh(membership)
|
||||
return membership
|
||||
Loading…
Add table
Add a link
Reference in a new issue