From bf5cd44dc166c1dd6d3ad38841bdb355fa1d3bf5 Mon Sep 17 00:00:00 2001 From: AnuragJangid18 Date: Fri, 23 Oct 2026 01:19:11 +0530 Subject: [PATCH] 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. --- README.md | 76 +++++++++ backend/app/main.py | 3 +- backend/app/models/tenant.py | 64 +++++++ backend/app/routers/documents.py | 16 +- backend/app/routers/tenant.py | 150 +++++++++++++++++ backend/app/schemas/document.py | 3 + backend/app/schemas/tenant.py | 58 +++++++ backend/app/services/document_service.py | 7 + backend/app/services/tenant_service.py | 159 ++++++++++++++++++ frontend/e2e/phase10-tenant.spec.ts | 19 +++ frontend/src/App.tsx | 59 ++++++- frontend/src/components/auth/LoginForm.tsx | 7 + .../components/dashboard/DashboardHeader.tsx | 18 +- .../components/dashboard/DashboardSidebar.tsx | 41 ++++- frontend/src/hooks/useDashboardDocuments.ts | 8 +- frontend/src/lib/api.ts | 60 +++++++ frontend/src/lib/documents.ts | 4 + frontend/src/pages/AccountPage.tsx | 109 ++++++++++++ frontend/src/pages/WorkspaceAdminPage.tsx | 90 ++++++++++ frontend/test-results/.last-run.json | 4 + 20 files changed, 928 insertions(+), 27 deletions(-) create mode 100644 backend/app/models/tenant.py create mode 100644 backend/app/routers/tenant.py create mode 100644 backend/app/schemas/tenant.py create mode 100644 backend/app/services/tenant_service.py create mode 100644 frontend/e2e/phase10-tenant.spec.ts create mode 100644 frontend/src/pages/AccountPage.tsx create mode 100644 frontend/src/pages/WorkspaceAdminPage.tsx create mode 100644 frontend/test-results/.last-run.json diff --git a/README.md b/README.md index 9aade78..2fa0bf5 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,39 @@ Validation completed: - FastAPI application import and route registration - `cd frontend && npm run test:e2e` (3 tests skipped without test credentials) +#### Phase 9 — Identity and account foundation (in progress) + +- Added a real profile/settings surface at `/profile` and `/settings`. +- Added profile updates for name, occupation, and phone. +- Added authenticated password changes with current-password verification. +- Added explicit sign-out behavior that clears the local session. +- Added a signed Google OAuth authorization-state flow: + `/api/v1/auth/google/login` and `/api/v1/auth/google/callback`. +- Added Google account provisioning by verified email and automatic login + session creation. +- Added the frontend OAuth callback route and connected the login form to the + backend Google flow. +- Added configuration for `FRONTEND_URL`, `GOOGLE_CLIENT_ID`, + `GOOGLE_CLIENT_SECRET`, and `GOOGLE_REDIRECT_URI`. +- Google sign-in remains explicitly disabled until provider credentials are + configured; the API returns a clear configuration error instead of a + success-shaped fallback. + +Validation completed: + +- `cd frontend && npm run build` +- `python -m compileall backend\app` +- FastAPI application import and route registration +- Backend requirements installation including `httpx` + +Next identity work: + +- Add refresh-token rotation and server-side session/device management. +- Add email verification, recovery email delivery, and account deletion. +- Add provider identity metadata and organization/workspace membership. +- Add security activity, login history, and configurable notification/privacy + settings. + ### Dashboard onboarding - Returning users see recent documents ordered by their last visited activity. @@ -216,3 +249,46 @@ The frontend build is verified with: cd frontend npm run build ``` + +#### Phase 10 — Organizations, workspaces, members, and tenant administration (in progress) + +- Added SQLAlchemy tenant tables for organizations, workspaces, workspace + memberships, and expiring single-use invitations. +- Added explicit tenant roles: owner, admin, member, and viewer. +- Added authenticated workspace listing and workspace creation. +- Added workspace member listing, role changes, invitation creation, and + invitation acceptance with email matching and expiry enforcement. +- Added invitation resend and revoke endpoints. +- Added workspace ownership transfer with organization-owner synchronization. +- Added tenant audit events for workspace creation, invitation creation, + invitation revocation, and ownership transfer. +- Added workspace-scoped MongoDB document metadata and dashboard filtering. +- Existing owner-owned spaces and pages are backfilled into the selected + workspace on first workspace load without changing their content or sharing. +- New document creation rejects workspace IDs the authenticated user cannot + access. +- Added a workspace administration page at `/settings/workspaces` for + creating organizations/workspaces, inviting members, and managing roles. +- Kept existing personal spaces and document-level sharing compatible while the + next tenant step associates MongoDB spaces with a selected workspace. +- Removed the redundant FAQ/help and settings icons from the dashboard header; + profile remains the single account entry point. +- Removed the unused Customize sidebar action and its controls. +- Updated Invite Fellow to create a workspace invitation and open a prefilled + email containing the invitation link. + +Validation completed: + +- `python -m compileall backend\app` +- FastAPI import and tenant route registration (`45` routes) +- `cd frontend && npm run build` +- `git diff --check` +- `cd frontend && npm run test:e2e` (Phase 10 tenant shell coverage; requires + `E2E_USERNAME` and `E2E_PASSWORD`) + +Next tenant work: + +- Add workspace context to spaces and page creation. +- Add invitation and audit history views to the administration page. +- Add organization-level security settings. +- Add authenticated browser coverage for workspace creation and role denial. diff --git a/backend/app/main.py b/backend/app/main.py index 891557c..b0d97ce 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py new file mode 100644 index 0000000..6537b23 --- /dev/null +++ b/backend/app/models/tenant.py @@ -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) diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index a1192ff..f28c37a 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -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) diff --git a/backend/app/routers/tenant.py b/backend/app/routers/tenant.py new file mode 100644 index 0000000..7daff05 --- /dev/null +++ b/backend/app/routers/tenant.py @@ -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) diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py index 6da7e9b..58409b9 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -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): diff --git a/backend/app/schemas/tenant.py b/backend/app/schemas/tenant.py new file mode 100644 index 0000000..6cd1ad8 --- /dev/null +++ b/backend/app/schemas/tenant.py @@ -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"] diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 5abe7a3..41997b1 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -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 diff --git a/backend/app/services/tenant_service.py b/backend/app/services/tenant_service.py new file mode 100644 index 0000000..242e9a6 --- /dev/null +++ b/backend/app/services/tenant_service.py @@ -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 diff --git a/frontend/e2e/phase10-tenant.spec.ts b/frontend/e2e/phase10-tenant.spec.ts new file mode 100644 index 0000000..e3f61d6 --- /dev/null +++ b/frontend/e2e/phase10-tenant.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test' +import { login } from './helpers' + +test('authenticated user can open workspace administration', async ({ page }) => { + test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD') + expect(await login(page)).toBe(true) + await page.goto('/settings/workspaces') + await expect(page.getByRole('heading', { name: /organizations and workspaces/i })).toBeVisible() + await expect(page.getByText(/your workspaces/i)).toBeVisible() +}) + +test('dashboard keeps the profile entry while removing redundant header controls', async ({ page }) => { + test.skip(!process.env.E2E_USERNAME || !process.env.E2E_PASSWORD, 'Set E2E_USERNAME and E2E_PASSWORD') + expect(await login(page)).toBe(true) + await page.goto('/dashboard') + await expect(page.getByRole('link', { name: /open profile/i })).toBeVisible() + await expect(page.getByRole('button', { name: 'Help' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Customize' })).toHaveCount(0) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f2338d..c9d0c16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ -import { Routes, Route } from 'react-router-dom' +import { Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom' +import { useEffect, useState } from 'react' import LandingPage from './pages/LandingPage' import LoginPage from './pages/LoginPage' import DashboardPage from './pages/DashboardPage' @@ -9,6 +10,37 @@ import SteadPage from './pages/workspace/SteadPage' import SteadCreatePage from './pages/workspace/SteadCreatePage' import ForbiddenPage from './pages/ForbiddenPage' import ProtectedRoute from './components/ProtectedRoute' +import AccountPage from './pages/AccountPage' +import WorkspaceAdminPage from './pages/WorkspaceAdminPage' +import { authApi, saveAuth } from './lib/api' + +function GoogleCallback() { + const [params] = useSearchParams() + const navigate = useNavigate() + const [error, setError] = useState(null) + + useEffect(() => { + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + if (!accessToken || !refreshToken) { + setError('Google sign-in did not return valid session credentials.') + return + } + localStorage.setItem('silos_access_token', accessToken) + localStorage.setItem('silos_refresh_token', refreshToken) + void authApi.me().then((user) => { + saveAuth({ access_token: accessToken, refresh_token: refreshToken, token_type: 'bearer', user }) + navigate('/dashboard', { replace: true }) + }).catch(() => { + localStorage.removeItem('silos_access_token') + localStorage.removeItem('silos_refresh_token') + setError('Google sign-in could not be completed.') + }) + }, [navigate, params]) + + if (error) return

{error}

+ return +} export default function App() { return ( @@ -23,6 +55,31 @@ export default function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + ) } diff --git a/frontend/src/components/dashboard/DashboardHeader.tsx b/frontend/src/components/dashboard/DashboardHeader.tsx index b25c436..8b3dccb 100644 --- a/frontend/src/components/dashboard/DashboardHeader.tsx +++ b/frontend/src/components/dashboard/DashboardHeader.tsx @@ -4,8 +4,6 @@ import { Menu, Search, Bell, - HelpCircle, - Settings, ChevronDown, } from 'lucide-react' import { logos } from '../../data/brandAssets' @@ -89,24 +87,12 @@ export default function DashboardHeader({ > - - - + setSearchOpen(false)} initialQuery={draft} /> diff --git a/frontend/src/components/dashboard/DashboardSidebar.tsx b/frontend/src/components/dashboard/DashboardSidebar.tsx index 7fdf2b6..1f0af67 100644 --- a/frontend/src/components/dashboard/DashboardSidebar.tsx +++ b/frontend/src/components/dashboard/DashboardSidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { FormEvent, useState } from 'react' import { Link } from 'react-router-dom' import { Home, @@ -9,12 +9,12 @@ import { ChevronDown, ChevronRight, Plus, - Palette, UserPlus, type LucideIcon, } from 'lucide-react' import { navItems, type NavItemId } from '../../data/dashboardData' import type { DocumentRecord } from '../../lib/documents' +import { tenantApi } from '../../lib/api' const iconMap: Record = { Home, @@ -42,6 +42,26 @@ export default function DashboardSidebar({ spaces = [], }: DashboardSidebarProps) { const [personalOpen, setPersonalOpen] = useState(true) + const [inviteOpen, setInviteOpen] = useState(false) + const [inviteEmail, setInviteEmail] = useState('') + const [inviteMessage, setInviteMessage] = useState(null) + + async function sendInvite(event: FormEvent) { + event.preventDefault() + try { + const workspaces = await tenantApi.listWorkspaces() + if (!workspaces[0]) { + setInviteMessage('Create a workspace before inviting people.') + return + } + const invitation = await tenantApi.invite(workspaces[0].id, inviteEmail, 'member') + window.location.href = `mailto:${encodeURIComponent(inviteEmail)}?subject=${encodeURIComponent(`Join ${workspaces[0].name} on Silos`)}&body=${encodeURIComponent(`You are invited to join ${workspaces[0].name} on Silos. Open this link to accept: ${invitation.invite_url ?? ''}`)}` + setInviteEmail('') + setInviteMessage('Your email app is ready with the invitation link.') + } catch (error) { + setInviteMessage(error instanceof Error ? error.message : 'Could not create an invitation.') + } + } if (collapsed) { return ( @@ -144,15 +164,22 @@ export default function DashboardSidebar({
- -
+ {inviteOpen && ( +
+

Invite a fellow

+

Create an invitation link and open it in your email app.

+
void sendInvite(event)} className="mt-3 space-y-2"> + setInviteEmail(event.target.value)} placeholder="person@example.com" required className="w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" /> + +
+ {inviteMessage &&

{inviteMessage}

} +
+ )} ) } diff --git a/frontend/src/hooks/useDashboardDocuments.ts b/frontend/src/hooks/useDashboardDocuments.ts index e326b49..3b47a0d 100644 --- a/frontend/src/hooks/useDashboardDocuments.ts +++ b/frontend/src/hooks/useDashboardDocuments.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { listDocuments, type DocumentRecord } from '../lib/documents' import { sortByRecent } from '../lib/documentUtils' +import { tenantApi } from '../lib/api' export function useDashboardDocuments() { const [documents, setDocuments] = useState([]) @@ -11,7 +12,12 @@ export function useDashboardDocuments() { setLoading(true) setError(null) try { - const docs = await listDocuments(undefined, { sort: 'last_visited_at' }) + const workspaces = await tenantApi.listWorkspaces() + const selected = Number(localStorage.getItem('silos_workspace_id')) || workspaces[0]?.id + if (selected) { + localStorage.setItem('silos_workspace_id', String(selected)) + } + const docs = await listDocuments(undefined, { sort: 'last_visited_at', workspaceId: selected }) setDocuments(docs) } catch { setError('Could not load documents. Is MongoDB running?') diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 76ebda0..38a669f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -13,6 +13,38 @@ export interface User { updated_at: string } +export type TenantRole = 'owner' | 'admin' | 'member' | 'viewer' + +export interface ApiWorkspace { + id: number + organization_id: number + organization_name: string + name: string + slug: string + role: TenantRole + created_at: string +} + +export interface ApiWorkspaceMember { + user_id: number + email: string + full_name: string + username: string + role: TenantRole + joined_at: string +} + +export interface ApiInvitation { + id: number + workspace_id: number + email: string + role: TenantRole + expires_at: string + accepted: boolean + token?: string + invite_url?: string +} + export interface AuthTokens { access_token: string refresh_token: string @@ -155,6 +187,29 @@ export const authApi = { googleLoginUrl: () => `${API_BASE}/auth/google/login`, } +export const tenantApi = { + listWorkspaces: () => request('/tenants/workspaces'), + createWorkspace: (organization_name: string, workspace_name: string) => + request('/tenants/workspaces', { + method: 'POST', + body: JSON.stringify({ organization_name, workspace_name }), + }), + listMembers: (workspaceId: number) => + request(`/tenants/workspaces/${workspaceId}/members`), + invite: (workspaceId: number, email: string, role: Exclude) => + request(`/tenants/workspaces/${workspaceId}/invitations`, { + method: 'POST', + body: JSON.stringify({ email, role }), + }), + updateRole: (workspaceId: number, userId: number, role: Exclude) => + request(`/tenants/workspaces/${workspaceId}/members/${userId}`, { + method: 'PATCH', + body: JSON.stringify({ role }), + }), + acceptInvitation: (token: string) => + request(`/tenants/invitations/${encodeURIComponent(token)}/accept`, { method: 'POST' }), +} + export function saveAuth(tokens: AuthTokens) { localStorage.setItem('silos_access_token', tokens.access_token) localStorage.setItem('silos_refresh_token', tokens.refresh_token) @@ -247,6 +302,7 @@ export interface ApiDocument { owner_id?: number | null visibility?: 'private' | 'members' members?: DocumentMember[] + workspace_id?: number | null } export interface ApiDocumentVersion { @@ -266,6 +322,7 @@ export interface DocumentCreatePayload { template_id?: string stead_id?: string parent_page_id?: string | null + workspace_id?: number | null } export interface DocumentUpdatePayload { @@ -274,6 +331,7 @@ export interface DocumentUpdatePayload { starred?: boolean stead_id?: string parent_page_id?: string + workspace_id?: number } export const documentsApi = { @@ -283,6 +341,7 @@ export const documentsApi = { parent_page_id?: string starred?: boolean sort?: 'updated_at' | 'last_visited_at' + workspace_id?: number }) => { const search = new URLSearchParams() if (params?.type) search.set('type', params.type) @@ -290,6 +349,7 @@ export const documentsApi = { if (params?.parent_page_id) search.set('parent_page_id', params.parent_page_id) if (params?.starred !== undefined) search.set('starred', String(params.starred)) if (params?.sort) search.set('sort', params.sort) + if (params?.workspace_id) search.set('workspace_id', String(params.workspace_id)) const qs = search.toString() return request(`/documents${qs ? `?${qs}` : ''}`) }, diff --git a/frontend/src/lib/documents.ts b/frontend/src/lib/documents.ts index 6dcc476..5f83b47 100644 --- a/frontend/src/lib/documents.ts +++ b/frontend/src/lib/documents.ts @@ -8,6 +8,7 @@ export interface DocumentMeta { templateId?: ShedTemplateId steadId?: string parentPageId?: string + workspaceId?: number createdAt: string updatedAt: string createdBy: string @@ -26,6 +27,7 @@ export interface ListDocumentsOptions { sort?: 'updated_at' | 'last_visited_at' starred?: boolean parentPageId?: string + workspaceId?: number } function fromApi(doc: ApiDocument): DocumentRecord { @@ -45,6 +47,7 @@ function fromApi(doc: ApiDocument): DocumentRecord { ownerId: doc.owner_id ?? undefined, visibility: doc.visibility, members: doc.members ?? [], + workspaceId: doc.workspace_id ?? undefined, } } @@ -57,6 +60,7 @@ export async function listDocuments( starred: options?.starred, sort: options?.sort, parent_page_id: options?.parentPageId, + workspace_id: options?.workspaceId, }) return docs.filter((doc) => !(doc.content?.archived === true)).map(fromApi) } diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx new file mode 100644 index 0000000..384944d --- /dev/null +++ b/frontend/src/pages/AccountPage.tsx @@ -0,0 +1,109 @@ +import { FormEvent, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { ArrowLeft, Check, LockKeyhole, UserRound } from 'lucide-react' +import { authApi, clearAuth, getStoredUser, saveAuth, type User } from '../lib/api' + +export default function AccountPage() { + const navigate = useNavigate() + const stored = getStoredUser() + const [user, setUser] = useState(stored) + const [fullName, setFullName] = useState(stored?.full_name ?? '') + const [occupation, setOccupation] = useState(stored?.occupation ?? '') + const [phone, setPhone] = useState(stored?.phone ?? '') + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [message, setMessage] = useState(null) + const [error, setError] = useState(null) + + async function saveProfile(event: FormEvent) { + event.preventDefault() + setMessage(null) + setError(null) + try { + const updated = await authApi.updateMe({ full_name: fullName, occupation, phone }) + setUser(updated) + saveAuth({ + access_token: localStorage.getItem('silos_access_token') ?? '', + refresh_token: localStorage.getItem('silos_refresh_token') ?? '', + token_type: 'bearer', + user: updated, + }) + setMessage('Profile updated.') + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not update profile.') + } + } + + async function changePassword(event: FormEvent) { + event.preventDefault() + setMessage(null) + setError(null) + try { + const result = await authApi.changePassword(currentPassword, newPassword) + setCurrentPassword('') + setNewPassword('') + setMessage(result.message) + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not change password.') + } + } + + function signOut() { + clearAuth() + navigate('/login') + } + + if (!user) return null + + return ( +
+
+ + Back to dashboard + +
+

Account

+

Profile and settings

+

Manage your identity, personal details, and account security.

+
+ {(message || error) &&
{!error && }{error ?? message}
} +
+ +
+
void saveProfile(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm"> +

Profile

+

This information is shown to people you collaborate with.

+
+ + + + + +
+ +
+
void changePassword(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm"> +

Security

+

Change your password. Google-authenticated users can continue using Google sign-in.

+
+ + +
+ +
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/WorkspaceAdminPage.tsx b/frontend/src/pages/WorkspaceAdminPage.tsx new file mode 100644 index 0000000..aeb6c42 --- /dev/null +++ b/frontend/src/pages/WorkspaceAdminPage.tsx @@ -0,0 +1,90 @@ +import { FormEvent, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { ArrowLeft, Building2, MailPlus, ShieldCheck, Users } from 'lucide-react' +import { tenantApi, type ApiWorkspace, type ApiWorkspaceMember, type TenantRole } from '../lib/api' + +export default function WorkspaceAdminPage() { + const [workspaces, setWorkspaces] = useState([]) + const [selected, setSelected] = useState(null) + const [members, setMembers] = useState([]) + const [organizationName, setOrganizationName] = useState('') + const [workspaceName, setWorkspaceName] = useState('') + const [email, setEmail] = useState('') + const [role, setRole] = useState>('member') + const [message, setMessage] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + void tenantApi.listWorkspaces().then((items) => { + setWorkspaces(items) + if (items[0]) setSelected(items[0]) + }).catch((err: unknown) => setError(err instanceof Error ? err.message : 'Could not load workspaces.')) + }, []) + + useEffect(() => { + if (!selected) { + setMembers([]) + return + } + void tenantApi.listMembers(selected.id).then(setMembers).catch((err: unknown) => setError(err instanceof Error ? err.message : 'Could not load members.')) + }, [selected]) + + async function createWorkspace(event: FormEvent) { + event.preventDefault() + try { + const workspace = await tenantApi.createWorkspace(organizationName, workspaceName) + setWorkspaces((items) => [...items, workspace]) + setSelected(workspace) + setOrganizationName('') + setWorkspaceName('') + setMessage('Workspace created.') + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not create workspace.') + } + } + + async function invite(event: FormEvent) { + event.preventDefault() + if (!selected) return + try { + await tenantApi.invite(selected.id, email, role) + setEmail('') + setMessage('Invitation created. Connect your email provider to deliver it automatically.') + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not invite member.') + } + } + + async function changeRole(userId: number, nextRole: Exclude) { + if (!selected) return + try { + const member = await tenantApi.updateRole(selected.id, userId, nextRole) + setMembers((items) => items.map((item) => item.user_id === member.user_id ? member : item)) + setMessage('Member role updated.') + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not update role.') + } + } + + return ( +
+
+ Back to settings +

Administration

Organizations and workspaces

Create collaboration boundaries, invite people, and manage workspace roles.

+ {(message || error) &&

{error ?? message}

} +
+
+

Your workspaces

+
{workspaces.map((workspace) => )}{workspaces.length === 0 &&

No workspaces yet.

}
+
void createWorkspace(event)} className="mt-5 border-t border-silos-cream-dark pt-4">

New workspace

setOrganizationName(event.target.value)} placeholder="Organization name" className="mt-2 w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" required /> setWorkspaceName(event.target.value)} placeholder="Workspace name" className="mt-2 w-full rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" required />
+
+ {selected ?
+

{selected.name}

{selected.organization_name} · Your role: {selected.role}

+

Members

{members.map((member) =>

{member.full_name}

{member.email} · @{member.username}

{member.role === 'owner' ? Owner : }
)}
+ {['owner', 'admin'].includes(selected.role) &&
void invite(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm">

Invite a member

setEmail(event.target.value)} placeholder="person@example.com" className="flex-1 rounded-lg border border-silos-cream-dark px-3 py-2 text-sm" required />
} +
:
Create or select a workspace to manage members.
} +
+
+
+ ) +} diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/frontend/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file