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

@ -194,6 +194,39 @@ Validation completed:
- FastAPI application import and route registration - FastAPI application import and route registration
- `cd frontend && npm run test:e2e` (3 tests skipped without test credentials) - `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 ### Dashboard onboarding
- Returning users see recent documents ordered by their last visited activity. - Returning users see recent documents ordered by their last visited activity.
@ -216,3 +249,46 @@ The frontend build is verified with:
cd frontend cd frontend
npm run build 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.

View file

@ -9,7 +9,7 @@ from sqlalchemy.exc import OperationalError
from app.config import settings from app.config import settings
from app.database import Base, engine from app.database import Base, engine
from app.mongo import close_mongo, connect_mongo 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.collaboration_service import ensure_collaboration_indexes
from app.services.document_service import ensure_indexes from app.services.document_service import ensure_indexes
from app.services.template_service import ensure_template_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(documents.router, prefix="/api/v1")
app.include_router(templates.router, prefix="/api/v1") app.include_router(templates.router, prefix="/api/v1")
app.include_router(collab.router, prefix="/api/v1") app.include_router(collab.router, prefix="/api/v1")
app.include_router(tenant.router, prefix="/api/v1")
@app.get("/") @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 motor.motor_asyncio import AsyncIOMotorDatabase
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_mongo_db_dep 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.models.user import User
from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest from app.schemas.document import DocumentCreate, DocumentResponse, DocumentUpdate, DocumentVersionResponse, ShareRequest
from app.schemas.collaboration import ( from app.schemas.collaboration import (
@ -199,10 +200,19 @@ async def list_docs(
stead_id: str | None = Query(None, description="Filter by parent stead"), stead_id: str | None = Query(None, description="Filter by parent stead"),
parent_page_id: str | None = Query(None, description="Filter by parent page"), parent_page_id: str | None = Query(None, description="Filter by parent page"),
starred: bool | None = Query(None, description="Filter starred projects"), 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"), sort: str = Query("updated_at", description="Sort field: updated_at or last_visited_at"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
auth_db: Session = Depends(get_db),
) -> list[DocumentResponse]: ) -> 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" sort_field = "last_visited_at" if sort == "last_visited_at" else "updated_at"
docs = await list_documents( docs = await list_documents(
db, db,
@ -212,6 +222,7 @@ async def list_docs(
parent_page_id=parent_page_id, parent_page_id=parent_page_id,
starred=starred, starred=starred,
sort=sort_field, sort=sort_field,
workspace_id=workspace_id,
) )
return [DocumentResponse(**doc) for doc in docs] return [DocumentResponse(**doc) for doc in docs]
@ -243,7 +254,10 @@ async def create_doc(
data: DocumentCreate, data: DocumentCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep), db: AsyncIOMotorDatabase = Depends(get_mongo_db_dep),
auth_db: Session = Depends(get_db),
) -> DocumentResponse: ) -> 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 created_by = current_user.full_name or current_user.username
try: try:
doc = await create_document(db, current_user.id, created_by, data) 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 template_id: str | None = None
stead_id: str | None = None stead_id: str | None = None
parent_page_id: str | None = None parent_page_id: str | None = None
workspace_id: int | None = None
class DocumentUpdate(BaseModel): class DocumentUpdate(BaseModel):
@ -30,6 +31,7 @@ class DocumentUpdate(BaseModel):
starred: bool | None = None starred: bool | None = None
stead_id: str | None = None stead_id: str | None = None
parent_page_id: str | None = None parent_page_id: str | None = None
workspace_id: int | None = None
class ShareRequest(BaseModel): class ShareRequest(BaseModel):
@ -53,6 +55,7 @@ class DocumentResponse(BaseModel):
owner_id: int | None = None owner_id: int | None = None
visibility: Visibility = "private" visibility: Visibility = "private"
members: list[DocumentMember] = Field(default_factory=list) members: list[DocumentMember] = Field(default_factory=list)
workspace_id: int | None = None
class DocumentVersionResponse(BaseModel): 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"), "owner_id": doc.get("owner_id"),
"visibility": doc.get("visibility", "private"), "visibility": doc.get("visibility", "private"),
"members": doc.get("members", []), "members": doc.get("members", []),
"workspace_id": doc.get("workspace_id"),
} }
@ -84,6 +85,7 @@ async def create_document(
"last_visited_at": now, "last_visited_at": now,
"visibility": "private", "visibility": "private",
"members": [], "members": [],
"workspace_id": data.workspace_id,
} }
await db[COLLECTION].insert_one(doc) await db[COLLECTION].insert_one(doc)
return _serialize(doc) return _serialize(doc)
@ -104,6 +106,7 @@ async def list_documents(
doc_type: CreateType | None = None, doc_type: CreateType | None = None,
stead_id: str | None = None, stead_id: str | None = None,
parent_page_id: str | None = None, parent_page_id: str | None = None,
workspace_id: int | None = None,
starred: bool | None = None, starred: bool | None = None,
sort: SortField = "updated_at", sort: SortField = "updated_at",
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@ -117,6 +120,8 @@ async def list_documents(
extras["parent_page_id"] = parent_page_id extras["parent_page_id"] = parent_page_id
if starred is not None: if starred is not None:
extras["starred"] = starred extras["starred"] = starred
if workspace_id is not None:
extras["workspace_id"] = workspace_id
if extras: if extras:
query = {"$and": [query, extras]} query = {"$and": [query, extras]}
@ -192,6 +197,8 @@ async def update_document(
updates["starred"] = data.starred updates["starred"] = data.starred
if data.stead_id is not None: if data.stead_id is not None:
updates["stead_id"] = data.stead_id 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: if "parent_page_id" in data.model_fields_set:
updates["parent_page_id"] = data.parent_page_id 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

View file

@ -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)
})

View file

@ -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 LandingPage from './pages/LandingPage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
@ -9,6 +10,37 @@ import SteadPage from './pages/workspace/SteadPage'
import SteadCreatePage from './pages/workspace/SteadCreatePage' import SteadCreatePage from './pages/workspace/SteadCreatePage'
import ForbiddenPage from './pages/ForbiddenPage' import ForbiddenPage from './pages/ForbiddenPage'
import ProtectedRoute from './components/ProtectedRoute' 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<string | null>(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 <main className="flex min-h-screen items-center justify-center bg-silos-cream p-6"><div className="rounded-2xl bg-white p-6 text-center shadow"><p className="text-sm text-red-700">{error}</p><button type="button" onClick={() => navigate('/login')} className="mt-4 rounded-lg bg-silos-brown px-4 py-2 text-sm text-white">Return to login</button></div></main>
return <Navigate to="/dashboard" replace />
}
export default function App() { export default function App() {
return ( return (
@ -23,6 +55,31 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/settings"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
<Route
path="/settings/workspaces"
element={
<ProtectedRoute>
<WorkspaceAdminPage />
</ProtectedRoute>
}
/>
<Route path="/auth/google/callback" element={<GoogleCallback />} />
<Route <Route
path="/forbidden" path="/forbidden"
element={ element={

View file

@ -99,6 +99,13 @@ export default function LoginForm({
</div> </div>
<SSOButtons onSelect={onSSOSelect} compact /> <SSOButtons onSelect={onSSOSelect} compact />
<button
type="button"
onClick={() => { window.location.href = `${import.meta.env.VITE_API_URL || '/api/v1'}/auth/google/login` }}
className="w-full rounded-xl border border-silos-cream-dark py-3 text-sm font-semibold text-silos-text transition-colors hover:bg-silos-cream"
>
Continue with Google
</button>
</form> </form>
) )
} }

View file

@ -4,8 +4,6 @@ import {
Menu, Menu,
Search, Search,
Bell, Bell,
HelpCircle,
Settings,
ChevronDown, ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import { logos } from '../../data/brandAssets' import { logos } from '../../data/brandAssets'
@ -89,24 +87,12 @@ export default function DashboardHeader({
> >
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
</button> </button>
<button <Link to="/profile" className="ml-1 flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2 transition-colors hover:bg-silos-cream" aria-label="Open profile">
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Help"
>
<HelpCircle className="h-5 w-5" />
</button>
<button
className="rounded-lg p-2 text-silos-muted transition-colors hover:bg-silos-cream hover:text-silos-text"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</button>
<button className="ml-1 flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2 transition-colors hover:bg-silos-cream">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-silos-tan text-sm font-semibold text-white"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-silos-tan text-sm font-semibold text-white">
{displayName[0]} {displayName[0]}
</div> </div>
<ChevronDown className="h-4 w-4 text-silos-muted" /> <ChevronDown className="h-4 w-4 text-silos-muted" />
</button> </Link>
</div> </div>
</header> </header>
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} initialQuery={draft} /> <SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} initialQuery={draft} />

View file

@ -1,4 +1,4 @@
import { useState } from 'react' import { FormEvent, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
Home, Home,
@ -9,12 +9,12 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Plus, Plus,
Palette,
UserPlus, UserPlus,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { navItems, type NavItemId } from '../../data/dashboardData' import { navItems, type NavItemId } from '../../data/dashboardData'
import type { DocumentRecord } from '../../lib/documents' import type { DocumentRecord } from '../../lib/documents'
import { tenantApi } from '../../lib/api'
const iconMap: Record<string, LucideIcon> = { const iconMap: Record<string, LucideIcon> = {
Home, Home,
@ -42,6 +42,26 @@ export default function DashboardSidebar({
spaces = [], spaces = [],
}: DashboardSidebarProps) { }: DashboardSidebarProps) {
const [personalOpen, setPersonalOpen] = useState(true) const [personalOpen, setPersonalOpen] = useState(true)
const [inviteOpen, setInviteOpen] = useState(false)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteMessage, setInviteMessage] = useState<string | null>(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) { if (collapsed) {
return ( return (
@ -144,15 +164,22 @@ export default function DashboardSidebar({
</nav> </nav>
<div className="space-y-2 border-t border-silos-cream-dark p-3"> <div className="space-y-2 border-t border-silos-cream-dark p-3">
<button className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream"> <button onClick={() => setInviteOpen(true)} className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream">
<Palette className="h-4 w-4" />
Customize
</button>
<button className="flex w-full items-center justify-center gap-2 rounded-xl border border-silos-cream-dark bg-silos-cream/40 py-2.5 text-sm font-medium text-silos-text transition-colors hover:bg-silos-cream">
<UserPlus className="h-4 w-4" /> <UserPlus className="h-4 w-4" />
Invite Fellow Invite Fellow
</button> </button>
</div> </div>
{inviteOpen && (
<div className="absolute bottom-20 left-3 right-3 z-40 rounded-2xl border border-silos-cream-dark bg-white p-4 shadow-xl">
<div className="flex items-center justify-between"><h2 className="text-sm font-semibold">Invite a fellow</h2><button type="button" onClick={() => setInviteOpen(false)} className="text-xs text-silos-muted">Close</button></div>
<p className="mt-2 text-xs text-silos-muted">Create an invitation link and open it in your email app.</p>
<form onSubmit={(event) => void sendInvite(event)} className="mt-3 space-y-2">
<input type="email" value={inviteEmail} onChange={(event) => 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" />
<button type="submit" className="w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Prepare email invite</button>
</form>
{inviteMessage && <p className="mt-2 text-xs text-silos-muted">{inviteMessage}</p>}
</div>
)}
</aside> </aside>
) )
} }

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { listDocuments, type DocumentRecord } from '../lib/documents' import { listDocuments, type DocumentRecord } from '../lib/documents'
import { sortByRecent } from '../lib/documentUtils' import { sortByRecent } from '../lib/documentUtils'
import { tenantApi } from '../lib/api'
export function useDashboardDocuments() { export function useDashboardDocuments() {
const [documents, setDocuments] = useState<DocumentRecord[]>([]) const [documents, setDocuments] = useState<DocumentRecord[]>([])
@ -11,7 +12,12 @@ export function useDashboardDocuments() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { 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) setDocuments(docs)
} catch { } catch {
setError('Could not load documents. Is MongoDB running?') setError('Could not load documents. Is MongoDB running?')

View file

@ -13,6 +13,38 @@ export interface User {
updated_at: string 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 { export interface AuthTokens {
access_token: string access_token: string
refresh_token: string refresh_token: string
@ -155,6 +187,29 @@ export const authApi = {
googleLoginUrl: () => `${API_BASE}/auth/google/login`, googleLoginUrl: () => `${API_BASE}/auth/google/login`,
} }
export const tenantApi = {
listWorkspaces: () => request<ApiWorkspace[]>('/tenants/workspaces'),
createWorkspace: (organization_name: string, workspace_name: string) =>
request<ApiWorkspace>('/tenants/workspaces', {
method: 'POST',
body: JSON.stringify({ organization_name, workspace_name }),
}),
listMembers: (workspaceId: number) =>
request<ApiWorkspaceMember[]>(`/tenants/workspaces/${workspaceId}/members`),
invite: (workspaceId: number, email: string, role: Exclude<TenantRole, 'owner'>) =>
request<ApiInvitation>(`/tenants/workspaces/${workspaceId}/invitations`, {
method: 'POST',
body: JSON.stringify({ email, role }),
}),
updateRole: (workspaceId: number, userId: number, role: Exclude<TenantRole, 'owner'>) =>
request<ApiWorkspaceMember>(`/tenants/workspaces/${workspaceId}/members/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ role }),
}),
acceptInvitation: (token: string) =>
request<ApiWorkspaceMember>(`/tenants/invitations/${encodeURIComponent(token)}/accept`, { method: 'POST' }),
}
export function saveAuth(tokens: AuthTokens) { export function saveAuth(tokens: AuthTokens) {
localStorage.setItem('silos_access_token', tokens.access_token) localStorage.setItem('silos_access_token', tokens.access_token)
localStorage.setItem('silos_refresh_token', tokens.refresh_token) localStorage.setItem('silos_refresh_token', tokens.refresh_token)
@ -247,6 +302,7 @@ export interface ApiDocument {
owner_id?: number | null owner_id?: number | null
visibility?: 'private' | 'members' visibility?: 'private' | 'members'
members?: DocumentMember[] members?: DocumentMember[]
workspace_id?: number | null
} }
export interface ApiDocumentVersion { export interface ApiDocumentVersion {
@ -266,6 +322,7 @@ export interface DocumentCreatePayload {
template_id?: string template_id?: string
stead_id?: string stead_id?: string
parent_page_id?: string | null parent_page_id?: string | null
workspace_id?: number | null
} }
export interface DocumentUpdatePayload { export interface DocumentUpdatePayload {
@ -274,6 +331,7 @@ export interface DocumentUpdatePayload {
starred?: boolean starred?: boolean
stead_id?: string stead_id?: string
parent_page_id?: string parent_page_id?: string
workspace_id?: number
} }
export const documentsApi = { export const documentsApi = {
@ -283,6 +341,7 @@ export const documentsApi = {
parent_page_id?: string parent_page_id?: string
starred?: boolean starred?: boolean
sort?: 'updated_at' | 'last_visited_at' sort?: 'updated_at' | 'last_visited_at'
workspace_id?: number
}) => { }) => {
const search = new URLSearchParams() const search = new URLSearchParams()
if (params?.type) search.set('type', params.type) 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?.parent_page_id) search.set('parent_page_id', params.parent_page_id)
if (params?.starred !== undefined) search.set('starred', String(params.starred)) if (params?.starred !== undefined) search.set('starred', String(params.starred))
if (params?.sort) search.set('sort', params.sort) if (params?.sort) search.set('sort', params.sort)
if (params?.workspace_id) search.set('workspace_id', String(params.workspace_id))
const qs = search.toString() const qs = search.toString()
return request<ApiDocument[]>(`/documents${qs ? `?${qs}` : ''}`) return request<ApiDocument[]>(`/documents${qs ? `?${qs}` : ''}`)
}, },

View file

@ -8,6 +8,7 @@ export interface DocumentMeta {
templateId?: ShedTemplateId templateId?: ShedTemplateId
steadId?: string steadId?: string
parentPageId?: string parentPageId?: string
workspaceId?: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
createdBy: string createdBy: string
@ -26,6 +27,7 @@ export interface ListDocumentsOptions {
sort?: 'updated_at' | 'last_visited_at' sort?: 'updated_at' | 'last_visited_at'
starred?: boolean starred?: boolean
parentPageId?: string parentPageId?: string
workspaceId?: number
} }
function fromApi(doc: ApiDocument): DocumentRecord { function fromApi(doc: ApiDocument): DocumentRecord {
@ -45,6 +47,7 @@ function fromApi(doc: ApiDocument): DocumentRecord {
ownerId: doc.owner_id ?? undefined, ownerId: doc.owner_id ?? undefined,
visibility: doc.visibility, visibility: doc.visibility,
members: doc.members ?? [], members: doc.members ?? [],
workspaceId: doc.workspace_id ?? undefined,
} }
} }
@ -57,6 +60,7 @@ export async function listDocuments(
starred: options?.starred, starred: options?.starred,
sort: options?.sort, sort: options?.sort,
parent_page_id: options?.parentPageId, parent_page_id: options?.parentPageId,
workspace_id: options?.workspaceId,
}) })
return docs.filter((doc) => !(doc.content?.archived === true)).map(fromApi) return docs.filter((doc) => !(doc.content?.archived === true)).map(fromApi)
} }

View file

@ -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<User | null>(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<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<main className="min-h-screen bg-silos-cream px-4 py-6 text-silos-text sm:px-8">
<div className="mx-auto max-w-4xl">
<Link to="/dashboard" className="mb-6 inline-flex items-center gap-2 text-sm text-silos-muted hover:text-silos-brown">
<ArrowLeft className="h-4 w-4" /> Back to dashboard
</Link>
<div className="mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-silos-tan">Account</p>
<h1 className="mt-1 text-3xl font-semibold">Profile and settings</h1>
<p className="mt-2 text-sm text-silos-muted">Manage your identity, personal details, and account security.</p>
</div>
{(message || error) && <div className={`mb-5 flex items-center gap-2 rounded-xl border px-4 py-3 text-sm ${error ? 'border-red-200 bg-red-50 text-red-800' : 'border-green-200 bg-green-50 text-green-800'}`}>{!error && <Check className="h-4 w-4" />}{error ?? message}</div>}
<div className="grid gap-5 lg:grid-cols-[220px_1fr]">
<aside className="h-fit rounded-2xl border border-silos-cream-dark bg-white p-2 shadow-sm">
<div className="flex items-center gap-3 rounded-xl bg-silos-cream px-3 py-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-silos-brown font-semibold text-white">{user.full_name[0]?.toUpperCase() ?? 'U'}</div>
<div className="min-w-0"><p className="truncate text-sm font-semibold">{user.full_name}</p><p className="truncate text-xs text-silos-muted">{user.email}</p></div>
</div>
<p className="px-3 pb-1 pt-4 text-[10px] font-semibold uppercase tracking-wider text-silos-muted">Settings</p>
<a href="#profile" className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-silos-brown hover:bg-silos-cream"><UserRound className="h-4 w-4" /> Profile</a>
<a href="#security" className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-silos-muted hover:bg-silos-cream"><LockKeyhole className="h-4 w-4" /> Security</a>
<Link to="/settings/workspaces" className="mt-1 block rounded-lg px-3 py-2 text-sm text-silos-muted hover:bg-silos-cream">Workspaces & members</Link>
<button type="button" onClick={signOut} className="mt-4 w-full rounded-lg px-3 py-2 text-left text-sm text-red-700 hover:bg-red-50">Sign out</button>
</aside>
<div className="space-y-5">
<form id="profile" onSubmit={(event) => void saveProfile(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm">
<h2 className="text-lg font-semibold">Profile</h2>
<p className="mt-1 text-sm text-silos-muted">This information is shown to people you collaborate with.</p>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium">Full name<input value={fullName} onChange={(event) => setFullName(event.target.value)} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" required /></label>
<label className="text-sm font-medium">Username<input value={user.username} readOnly className="mt-1.5 w-full rounded-xl border border-silos-cream-dark bg-silos-cream px-3 py-2.5 font-normal text-silos-muted" /></label>
<label className="text-sm font-medium">Email<input value={user.email} readOnly className="mt-1.5 w-full rounded-xl border border-silos-cream-dark bg-silos-cream px-3 py-2.5 font-normal text-silos-muted" /></label>
<label className="text-sm font-medium">Occupation<input value={occupation} onChange={(event) => setOccupation(event.target.value)} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" /></label>
<label className="text-sm font-medium sm:col-span-2">Phone<input value={phone} onChange={(event) => setPhone(event.target.value)} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" /></label>
</div>
<button type="submit" className="mt-5 rounded-xl bg-silos-brown px-4 py-2.5 text-sm font-semibold text-white hover:bg-silos-brown-dark">Save profile</button>
</form>
<form id="security" onSubmit={(event) => void changePassword(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm">
<h2 className="text-lg font-semibold">Security</h2>
<p className="mt-1 text-sm text-silos-muted">Change your password. Google-authenticated users can continue using Google sign-in.</p>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="text-sm font-medium">Current password<input type="password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" required /></label>
<label className="text-sm font-medium">New password<input type="password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} minLength={8} className="mt-1.5 w-full rounded-xl border border-silos-cream-dark px-3 py-2.5 font-normal outline-none focus:border-silos-brown" required /></label>
</div>
<button type="submit" className="mt-5 rounded-xl border border-silos-cream-dark px-4 py-2.5 text-sm font-semibold text-silos-text hover:bg-silos-cream">Change password</button>
</form>
</div>
</div>
</div>
</main>
)
}

View file

@ -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<ApiWorkspace[]>([])
const [selected, setSelected] = useState<ApiWorkspace | null>(null)
const [members, setMembers] = useState<ApiWorkspaceMember[]>([])
const [organizationName, setOrganizationName] = useState('')
const [workspaceName, setWorkspaceName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<Exclude<TenantRole, 'owner'>>('member')
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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<TenantRole, 'owner'>) {
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 (
<main className="min-h-screen bg-silos-cream px-4 py-6 text-silos-text sm:px-8">
<div className="mx-auto max-w-6xl">
<Link to="/settings" className="mb-6 inline-flex items-center gap-2 text-sm text-silos-muted hover:text-silos-brown"><ArrowLeft className="h-4 w-4" /> Back to settings</Link>
<div className="mb-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-silos-tan">Administration</p><h1 className="mt-1 text-3xl font-semibold">Organizations and workspaces</h1><p className="mt-2 text-sm text-silos-muted">Create collaboration boundaries, invite people, and manage workspace roles.</p></div>
{(message || error) && <p className={`mb-5 rounded-xl border px-4 py-3 text-sm ${error ? 'border-red-200 bg-red-50 text-red-800' : 'border-green-200 bg-green-50 text-green-800'}`}>{error ?? message}</p>}
<div className="grid gap-5 lg:grid-cols-[280px_1fr]">
<section className="rounded-2xl border border-silos-cream-dark bg-white p-4 shadow-sm">
<div className="flex items-center justify-between"><h2 className="font-semibold">Your workspaces</h2><Building2 className="h-4 w-4 text-silos-tan" /></div>
<div className="mt-3 space-y-2">{workspaces.map((workspace) => <button type="button" key={workspace.id} onClick={() => setSelected(workspace)} className={`w-full rounded-xl border px-3 py-3 text-left ${selected?.id === workspace.id ? 'border-silos-brown bg-silos-cream' : 'border-silos-cream-dark hover:bg-silos-cream/50'}`}><p className="text-sm font-medium">{workspace.name}</p><p className="mt-1 text-xs text-silos-muted">{workspace.organization_name} · {workspace.role}</p></button>)}{workspaces.length === 0 && <p className="text-sm text-silos-muted">No workspaces yet.</p>}</div>
<form onSubmit={(event) => void createWorkspace(event)} className="mt-5 border-t border-silos-cream-dark pt-4"><p className="text-xs font-semibold uppercase tracking-wider text-silos-muted">New workspace</p><input value={organizationName} onChange={(event) => 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 /><input value={workspaceName} onChange={(event) => 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 /><button type="submit" className="mt-2 w-full rounded-lg bg-silos-brown px-3 py-2 text-sm font-medium text-white">Create workspace</button></form>
</section>
{selected ? <section className="space-y-5">
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm"><div className="flex items-start justify-between"><div><h2 className="text-xl font-semibold">{selected.name}</h2><p className="mt-1 text-sm text-silos-muted">{selected.organization_name} · Your role: {selected.role}</p></div><ShieldCheck className="h-5 w-5 text-silos-tan" /></div></div>
<div className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm"><div className="flex items-center gap-2"><Users className="h-4 w-4 text-silos-brown" /><h2 className="font-semibold">Members</h2></div><div className="mt-4 divide-y divide-silos-cream-dark">{members.map((member) => <div key={member.user_id} className="flex items-center justify-between gap-3 py-3"><div><p className="text-sm font-medium">{member.full_name}</p><p className="text-xs text-silos-muted">{member.email} · @{member.username}</p></div>{member.role === 'owner' ? <span className="text-xs font-medium text-silos-brown">Owner</span> : <select value={member.role} onChange={(event) => void changeRole(member.user_id, event.target.value as Exclude<TenantRole, 'owner'>)} disabled={!['owner', 'admin'].includes(selected.role)} className="rounded-lg border border-silos-cream-dark px-2 py-1 text-xs"><option value="admin">Admin</option><option value="member">Member</option><option value="viewer">Viewer</option></select>}</div>)}</div></div>
{['owner', 'admin'].includes(selected.role) && <form onSubmit={(event) => void invite(event)} className="rounded-2xl border border-silos-cream-dark bg-white p-5 shadow-sm"><div className="flex items-center gap-2"><MailPlus className="h-4 w-4 text-silos-brown" /><h2 className="font-semibold">Invite a member</h2></div><div className="mt-4 flex flex-col gap-2 sm:flex-row"><input type="email" value={email} onChange={(event) => 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 /><select value={role} onChange={(event) => setRole(event.target.value as Exclude<TenantRole, 'owner'>)} className="rounded-lg border border-silos-cream-dark px-3 py-2 text-sm"><option value="member">Member</option><option value="admin">Admin</option><option value="viewer">Viewer</option></select><button type="submit" className="rounded-lg bg-silos-brown px-4 py-2 text-sm font-medium text-white">Invite</button></div></form>}
</section> : <section className="rounded-2xl border border-dashed border-silos-cream-dark bg-white p-10 text-center text-sm text-silos-muted">Create or select a workspace to manage members.</section>}
</div>
</div>
</main>
)
}

View file

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}