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:
Anurag Jangid 2026-10-23 01:19:11 +05:30
parent 809235e962
commit bf5cd44dc1
20 changed files with 928 additions and 27 deletions

View file

@ -9,7 +9,7 @@ from sqlalchemy.exc import OperationalError
from app.config import settings
from app.database import Base, engine
from app.mongo import close_mongo, connect_mongo
from app.routers import auth, collab, documents, health, templates
from app.routers import auth, collab, documents, health, templates, tenant
from app.services.collaboration_service import ensure_collaboration_indexes
from app.services.document_service import ensure_indexes
from app.services.template_service import ensure_template_indexes
@ -57,6 +57,7 @@ app.include_router(auth.router, prefix="/api/v1")
app.include_router(documents.router, prefix="/api/v1")
app.include_router(templates.router, prefix="/api/v1")
app.include_router(collab.router, prefix="/api/v1")
app.include_router(tenant.router, prefix="/api/v1")
@app.get("/")

View file

@ -0,0 +1,64 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), nullable=False)
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True, nullable=False)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class Workspace(Base):
__tablename__ = "workspaces"
id: Mapped[int] = mapped_column(primary_key=True)
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(160), nullable=False)
slug: Mapped[str] = mapped_column(String(180), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
__table_args__ = (UniqueConstraint("organization_id", "slug", name="uq_workspace_org_slug"),)
class WorkspaceMembership(Base):
__tablename__ = "workspace_memberships"
id: Mapped[int] = mapped_column(primary_key=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"), index=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
role: Mapped[str] = mapped_column(String(30), default="member", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
__table_args__ = (UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),)
class WorkspaceInvitation(Base):
__tablename__ = "workspace_invitations"
id: Mapped[int] = mapped_column(primary_key=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id"), index=True, nullable=False)
inviter_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
email: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
role: Mapped[str] = mapped_column(String(30), default="member", nullable=False)
token: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
accepted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class TenantAuditEvent(Base):
__tablename__ = "tenant_audit_events"
id: Mapped[int] = mapped_column(primary_key=True)
organization_id: Mapped[int] = mapped_column(ForeignKey("organizations.id"), index=True, nullable=False)
workspace_id: Mapped[int | None] = mapped_column(ForeignKey("workspaces.id"), index=True)
actor_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
action: Mapped[str] = mapped_column(String(80), nullable=False)
target: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View file

@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_mongo_db_dep
from app.database import get_db
from app.services.tenant_service import get_workspace
from app.models.user import User
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest
from app.schemas.collaboration import (
@ -199,10 +200,19 @@ async def list_docs(
stead_id: str | None = Query(None, description="Filter by parent stead"),
parent_page_id: str | None = Query(None, description="Filter by parent page"),
starred: bool | None = Query(None, description="Filter starred projects"),
workspace_id: int | None = Query(None, description="Filter by workspace"),
sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
auth_db: Session = Depends(get_db),
) -> list[DocumentResponse]:
if workspace_id is not None:
if get_workspace(auth_db, workspace_id, current_user.id) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
await db["documents"].update_many(
{"owner_id": current_user.id, "workspace_id": {"$exists": False}},
{"$set": {"workspace_id": workspace_id}},
)
sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at"
docs = await list_documents(
db,
@ -212,6 +222,7 @@ async def list_docs(
parent_page_id=parent_page_id,
starred=starred,
sort=sort_field,
workspace_id=workspace_id,
)
return [DocumentResponse(**doc) for doc in docs]
@ -243,7 +254,10 @@ async def create_doc(
data: DocumentCreate,
current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
auth_db: Session = Depends(get_db),
) -> DocumentResponse:
if data.workspace_id is not None and get_workspace(auth_db, data.workspace_id, current_user.id) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
created_by = current_user.full_name or current_user.username
try:
doc = await create_document(db, current_user.id, created_by, data)

View file

@ -0,0 +1,150 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.models.tenant import WorkspaceInvitation
from app.schemas.tenant import (
InvitationCreate,
InvitationResponse,
MemberResponse,
RoleUpdate,
AuditEventResponse,
WorkspaceCreate,
WorkspaceResponse,
)
from app.config import settings
from app.services.tenant_service import (
accept_invitation,
can_manage,
create_workspace,
get_membership,
get_workspace,
invite_member,
list_audit_events,
revoke_invitation,
transfer_ownership,
list_members,
list_workspaces,
update_member_role,
)
router = APIRouter(prefix="/tenants", tags=["tenants"])
def workspace_response(row) -> WorkspaceResponse:
workspace, organization, role = row
return WorkspaceResponse(
id=workspace.id,
organization_id=organization.id,
organization_name=organization.name,
name=workspace.name,
slug=workspace.slug,
role=role,
created_at=workspace.created_at,
)
@router.get("/workspaces", response_model=list[WorkspaceResponse])
def get_workspaces(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return [workspace_response(row) for row in list_workspaces(db, current_user.id)]
@router.post("/workspaces", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
def post_workspace(data: WorkspaceCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
workspace = create_workspace(db, current_user, data)
row = get_workspace(db, workspace.id, current_user.id)
return workspace_response(row)
@router.get("/workspaces/{workspace_id}/members", response_model=list[MemberResponse])
def get_workspace_members(workspace_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
if get_workspace(db, workspace_id, current_user.id) is None:
raise HTTPException(status_code=403, detail="Workspace not found or access denied")
return [
MemberResponse(user_id=user.id, email=user.email, full_name=user.full_name, username=user.username, role=membership.role, joined_at=membership.created_at)
for user, membership in list_members(db, workspace_id)
]
@router.post("/workspaces/{workspace_id}/invitations", response_model=InvitationResponse, status_code=201)
def post_invitation(workspace_id: int, data: InvitationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or not can_manage(row[2]):
raise HTTPException(status_code=403, detail="Only workspace owners and admins can invite members")
invitation = invite_member(db, workspace_id, current_user.id, str(data.email), data.role)
return InvitationResponse(
id=invitation.id,
workspace_id=invitation.workspace_id,
email=invitation.email,
role=invitation.role,
expires_at=invitation.expires_at,
accepted=invitation.accepted,
token=invitation.token,
invite_url=f"{settings.frontend_url}/invite/{invitation.token}",
)
@router.delete("/workspaces/{workspace_id}/invitations/{invitation_id}", status_code=204)
def delete_invitation(workspace_id: int, invitation_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or not can_manage(row[2]) or not revoke_invitation(db, workspace_id, invitation_id, current_user.id):
raise HTTPException(status_code=404, detail="Invitation not found")
@router.post("/workspaces/{workspace_id}/invitations/{invitation_id}/resend", response_model=InvitationResponse)
def resend_invitation(workspace_id: int, invitation_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
invitation = db.query(WorkspaceInvitation).filter_by(id=invitation_id, workspace_id=workspace_id, accepted=False).first()
if row is None or not can_manage(row[2]) or invitation is None:
raise HTTPException(status_code=404, detail="Invitation not found")
invitation.accepted = True
db.commit()
replacement = invite_member(db, workspace_id, current_user.id, invitation.email, invitation.role)
return InvitationResponse(
id=replacement.id,
workspace_id=replacement.workspace_id,
email=replacement.email,
role=replacement.role,
expires_at=replacement.expires_at,
accepted=replacement.accepted,
token=replacement.token,
invite_url=f"{settings.frontend_url}/invite/{replacement.token}",
)
@router.post("/workspaces/{workspace_id}/ownership/{user_id}", response_model=WorkspaceResponse)
def post_transfer_ownership(workspace_id: int, user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or row[2] != "owner" or not transfer_ownership(db, workspace_id, current_user.id, user_id):
raise HTTPException(status_code=403, detail="Only the workspace owner can transfer ownership")
return workspace_response(get_workspace(db, workspace_id, user_id))
@router.get("/workspaces/{workspace_id}/audit", response_model=list[AuditEventResponse])
def get_audit(workspace_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or not can_manage(row[2]):
raise HTTPException(status_code=403, detail="Only workspace admins can view audit history")
return [AuditEventResponse.model_validate(event, from_attributes=True) for event in list_audit_events(db, workspace_id)]
@router.post("/invitations/{token}/accept", response_model=MemberResponse)
def post_accept_invitation(token: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
membership = accept_invitation(db, token, current_user)
if membership is None:
raise HTTPException(status_code=400, detail="Invitation is invalid, expired, or not addressed to this account")
return MemberResponse(user_id=current_user.id, email=current_user.email, full_name=current_user.full_name, username=current_user.username, role=membership.role, joined_at=membership.created_at)
@router.patch("/workspaces/{workspace_id}/members/{user_id}", response_model=MemberResponse)
def patch_member_role(workspace_id: int, user_id: int, data: RoleUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
row = get_workspace(db, workspace_id, current_user.id)
if row is None or not can_manage(row[2]):
raise HTTPException(status_code=403, detail="Only workspace owners and admins can manage roles")
membership = update_member_role(db, workspace_id, user_id, data.role)
target = db.query(User).filter(User.id == user_id).first()
if membership is None or target is None:
raise HTTPException(status_code=400, detail="Member role cannot be changed")
return MemberResponse(user_id=target.id, email=target.email, full_name=target.full_name, username=target.username, role=membership.role, joined_at=membership.created_at)

View file

@ -22,6 +22,7 @@ class DocumentCreate(BaseModel):
template_id: str | None = None
stead_id: str | None = None
parent_page_id: str | None = None
workspace_id: int | None = None
class DocumentUpdate(BaseModel):
@ -30,6 +31,7 @@ class DocumentUpdate(BaseModel):
starred: bool | None = None
stead_id: str | None = None
parent_page_id: str | None = None
workspace_id: int | None = None
class ShareRequest(BaseModel):
@ -53,6 +55,7 @@ class DocumentResponse(BaseModel):
owner_id: int | None = None
visibility: Visibility = "private"
members: list[DocumentMember] = Field(default_factory=list)
workspace_id: int | None = None
class DocumentVersionResponse(BaseModel):

View file

@ -0,0 +1,58 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field
TenantRole = Literal["owner", "admin", "member", "viewer"]
class WorkspaceCreate(BaseModel):
organization_name: str = Field(min_length=2, max_length=160)
workspace_name: str = Field(min_length=2, max_length=160)
class WorkspaceResponse(BaseModel):
id: int
organization_id: int
organization_name: str
name: str
slug: str
role: TenantRole
created_at: datetime
class MemberResponse(BaseModel):
user_id: int
email: EmailStr
full_name: str
username: str
role: TenantRole
joined_at: datetime
class InvitationCreate(BaseModel):
email: EmailStr
role: Literal["admin", "member", "viewer"] = "member"
class InvitationResponse(BaseModel):
id: int
workspace_id: int
email: EmailStr
role: TenantRole
expires_at: datetime
accepted: bool
token: str | None = None
invite_url: str | None = None
class AuditEventResponse(BaseModel):
id: int
action: str
target: str
actor_id: int
created_at: datetime
class RoleUpdate(BaseModel):
role: Literal["admin", "member", "viewer"]

View file

@ -40,6 +40,7 @@ def _serialize(doc: dict[str, Any]) -> dict[str, Any]:
"owner_id": doc.get("owner_id"),
"visibility": doc.get("visibility", "private"),
"members": doc.get("members", []),
"workspace_id": doc.get("workspace_id"),
}
@ -84,6 +85,7 @@ async def create_document(
"last_visited_at": now,
"visibility": "private",
"members": [],
"workspace_id": data.workspace_id,
}
await db[COLLECTION].insert_one(doc)
return _serialize(doc)
@ -104,6 +106,7 @@ async def list_documents(
doc_type: CreateType | None = None,
stead_id: str | None = None,
parent_page_id: str | None = None,
workspace_id: int | None = None,
starred: bool | None = None,
sort: SortField = "updated_at",
) -> list[dict[str, Any]]:
@ -117,6 +120,8 @@ async def list_documents(
extras["parent_page_id"] = parent_page_id
if starred is not None:
extras["starred"] = starred
if workspace_id is not None:
extras["workspace_id"] = workspace_id
if extras:
query = {"$and": [query, extras]}
@ -192,6 +197,8 @@ async def update_document(
updates["starred"] = data.starred
if data.stead_id is not None:
updates["stead_id"] = data.stead_id
if data.workspace_id is not None:
updates["workspace_id"] = data.workspace_id
if "parent_page_id" in data.model_fields_set:
updates["parent_page_id"] = data.parent_page_id

View 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