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